diff --git a/docs/reference/community-license.md b/docs/reference/community-license.md index c1499fac..776165f3 100644 --- a/docs/reference/community-license.md +++ b/docs/reference/community-license.md @@ -40,7 +40,7 @@ Contact Us 폼 UI 기반 신청 경로: `/en/querypie/license/community/apply`, 1. **필수 필드 검증** — `FirstName`, `LastName`, `Email`, `Company` 누락 시 `400` 반환 2. **MX 레코드 검증** — 이메일 도메인의 MX 레코드가 없으면 `{success: false, errorMessage: "Please enter a valid email address."}` 반환 (2초 딜레이 포함) 3. **XSS 필터링** — `xss` 패키지의 `filterXSS`로 모든 텍스트 필드 처리; `Company`가 빈 값이면 `"None"` 대입 -4. **라이선스 발급** (`issueLicense`) — `QUERYPIE_LICENSE_ISSUE_API_ENDPOINT`, `QUERYPIE_LICENSE_ISSUE_API_KEY` 미설정 시 skip; 설정된 경우 API 호출 실패 시 전체 흐름 중단 +4. **라이선스 발급** (`issueLicense`) — `DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT`, `PUBLIC_API_KEY` 미설정 시 skip; 설정된 경우 `201` 응답은 `licenseId`, `customerCompanyId`를 반환하며 validation/business 실패는 non-2xx로 전체 흐름 중단 5. **Slack 알림** — 실패해도 전체 흐름에 영향 없음 (에러 swallow) 6. **응답** — `{success: true}` @@ -50,8 +50,8 @@ Contact Us 폼 UI 기반 신청 경로: `/en/querypie/license/community/apply`, | 변수 | 필수 | 설명 | |------|------|------| -| `QUERYPIE_LICENSE_ISSUE_API_ENDPOINT` | 선택 | 라이선스 발급 API URL. 미설정 시 발급 단계 skip | -| `QUERYPIE_LICENSE_ISSUE_API_KEY` | 선택 | 라이선스 발급 API 키. 미설정 시 발급 단계 skip | +| `DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT` | 선택 | 라이선스 발급 API URL. 미설정 시 발급 단계 skip | +| `PUBLIC_API_KEY` | 선택 | 라이선스 발급 API 키. 미설정 시 발급 단계 skip | | `SLACK_BOT_OAUTH_TOKEN` | 선택 | Slack Bot 토큰. 미설정 시 Slack 알림 skip | | `SLACK_CHANNEL_ALERT_WEBSITE_BUSINESS_INQUIRIES` | 선택 | Slack 채널 ID | | `SLACK_CHANNEL_ALERT_WEBSITE_FORM_SUBMISSION_TESTING` | 선택 | non-production 입력폼 알림 채널 ID | diff --git a/docs/reference/contact-us-api.md b/docs/reference/contact-us-api.md index 64aef54a..f359fabf 100644 --- a/docs/reference/contact-us-api.md +++ b/docs/reference/contact-us-api.md @@ -66,7 +66,7 @@ 1. **필수 필드 검증** — `firstName`, `lastName`, `email`, `company`, `departmentTitle` 중 누락 시 `400`. 2. **MX 레코드 검증** — 이메일 도메인의 MX 레코드 확인. 실패 시 2초 딜레이 후 에러 반환 (brute-force 완화). 3. **XSS 필터링** — 모든 문자열 필드에 적용. -4. **DeskPie Lead API 전송** (best-effort) — `DESKPIE_LEAD_API_ENDPOINT`, `DESKPIE_LEAD_API_KEY`가 모두 있으면 Next.js `after()`로 전송한다. 실패해도 에러 로그만 남긴다. +4. **DeskPie Lead API 전송** (best-effort) — `DESKPIE_LEAD_API_ENDPOINT`, `PUBLIC_API_KEY`가 모두 있으면 Next.js `after()`로 전송한다. 실패해도 에러 로그만 남긴다. 5. **Slack 알림** (best-effort) — 환경변수가 없거나 전송에 실패해도 에러 로그만 남기고 성공 응답 유지. 6. **성공 응답** — `{ success: true }`. @@ -100,7 +100,7 @@ DeskPie API는 기존 lead field name과 호환되는 payload를 받는다. Slac |------|------|------| | 필수 필드 누락 | 400 | `{ success: false, errorCode: "missing_required_fields", errorMessage: "Required fields are missing." }` | | MX 레코드 없음 | 200 | `{ success: false, errorCode: "invalid_email", errorMessage: "Please enter a valid email address." }` | -| DeskPie 환경변수 미설정 | 200 | `DESKPIE_LEAD_API_ENDPOINT`, `DESKPIE_LEAD_API_KEY` 중 하나라도 없으면 DeskPie 연동 skip, `{ success: true }` | +| DeskPie 환경변수 미설정 | 200 | `DESKPIE_LEAD_API_ENDPOINT`, `PUBLIC_API_KEY` 중 하나라도 없으면 DeskPie 연동 skip, `{ success: true }` | | DeskPie 실패 | 200 | 에러 로그만 남기고 `{ success: true }` | | Slack 환경변수 미설정 | 200 | Slack 알림 skip, `{ success: true }` | | Slack 실패 | 200 | 에러 로그만 남기고 `{ success: true }` | @@ -116,7 +116,7 @@ DeskPie API는 기존 lead field name과 호환되는 payload를 받는다. Slac | `SLACK_CHANNEL_ALERT_WEBSITE_BUSINESS_INQUIRIES` | 선택 | Slack 알림 skip | | `SLACK_CHANNEL_ALERT_WEBSITE_FORM_SUBMISSION_TESTING` | 선택 | non-production에서 미설정 시 `C083Y0300M7` fallback | | `DESKPIE_LEAD_API_ENDPOINT` | 선택 | DeskPie 단계 skip | -| `DESKPIE_LEAD_API_KEY` | 선택 | DeskPie 단계 skip | +| `PUBLIC_API_KEY` | 선택 | DeskPie 단계 skip | Slack 알림 채널 선택은 Community License, 콘텐츠 게이팅 폼과 같은 resolver를 공유한다. diff --git a/docs/reference/lead-capture-forms.md b/docs/reference/lead-capture-forms.md index 68d19dfa..90d842a1 100644 --- a/docs/reference/lead-capture-forms.md +++ b/docs/reference/lead-capture-forms.md @@ -43,7 +43,7 @@ QueryPie Community License를 신청·발급하는 폼. 백엔드는 라이선 |------|------| | 주요 컴포넌트 | `src/components/pages/contact/ContactForm.tsx` | | copy 원본 | `src/copy/contact.ts` | -| 환경변수 | `SLACK_BOT_OAUTH_TOKEN`, `SLACK_CHANNEL_ALERT_WEBSITE_BUSINESS_INQUIRIES`, `SLACK_CHANNEL_ALERT_WEBSITE_FORM_SUBMISSION_TESTING`, `DESKPIE_LEAD_API_ENDPOINT`, `DESKPIE_LEAD_API_KEY` (모두 선택) | +| 환경변수 | `SLACK_BOT_OAUTH_TOKEN`, `SLACK_CHANNEL_ALERT_WEBSITE_BUSINESS_INQUIRIES`, `SLACK_CHANNEL_ALERT_WEBSITE_FORM_SUBMISSION_TESTING`, `DESKPIE_LEAD_API_ENDPOINT`, `PUBLIC_API_KEY` (모두 선택) | | 성공 조건 | 필수값 검증과 MX 검증 통과 | | DeskPie | best-effort (실패해도 성공 응답 유지) | | UTM | `utm-attribution` 쿠키를 읽어 DeskPie/Slack payload에 포함 | @@ -166,7 +166,7 @@ PDF 버튼 동작: | `SLACK_CHANNEL_ALERT_WEBSITE_BUSINESS_INQUIRIES` | Slack 알림 채널 | — | | `SLACK_CHANNEL_ALERT_WEBSITE_FORM_SUBMISSION_TESTING` | non-production 입력폼 알림 채널 override | — | | `DESKPIE_LEAD_API_ENDPOINT` | Contact Us DeskPie Lead API endpoint | — | -| `DESKPIE_LEAD_API_KEY` | Contact Us DeskPie Lead API key | — | +| `PUBLIC_API_KEY` | Contact Us DeskPie Lead API key | — | Slack/DeskPie 환경변수가 없거나 전송에 실패해도 제출 성공은 유지한다. diff --git a/docs/reference/staging-lead-form.env.example b/docs/reference/staging-lead-form.env.example index 24360e0e..c91a47c9 100644 --- a/docs/reference/staging-lead-form.env.example +++ b/docs/reference/staging-lead-form.env.example @@ -27,12 +27,13 @@ SLACK_CHANNEL_ALERT_WEBSITE_BUSINESS_INQUIRIES=C083Y0300M7 # VERCEL_TARGET_ENV is not "production". SLACK_CHANNEL_ALERT_WEBSITE_FORM_SUBMISSION_TESTING=C083Y0300M7 +# Shared by DeskPie public Lead and Community License APIs. +PUBLIC_API_KEY= + # Required if Contact Us submissions should create DeskPie leads. # Example local endpoint: http://localhost:8011/api/v1/public/leads DESKPIE_LEAD_API_ENDPOINT= -DESKPIE_LEAD_API_KEY= # Required only for Community License automatic license issuance. # Staging currently uses the same public license endpoint as production. -QUERYPIE_LICENSE_ISSUE_API_ENDPOINT=https://license.querypie.com/license/community -QUERYPIE_LICENSE_ISSUE_API_KEY= +DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT=https://api.deskpie.querypie.com/api/v1/public/community-licenses diff --git a/docs/reference/vercel-deployment.md b/docs/reference/vercel-deployment.md index 494c9c23..3473e03c 100644 --- a/docs/reference/vercel-deployment.md +++ b/docs/reference/vercel-deployment.md @@ -155,14 +155,14 @@ Vercel 프로젝트에 설정해야 하는 기준값. 실제 등록값은 Vercel | 변수 | Production | Staging | Preview | Development | |------|------------|---------|---------|-------------| -| `QUERYPIE_LICENSE_ISSUE_API_ENDPOINT` | `https://license.querypie.com/license/community` | ← 동일 | ← 동일 | — | -| `QUERYPIE_LICENSE_ISSUE_API_KEY` | Encrypted | ← 동일 | ← 동일 | — | +| `DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT` | `https://api.deskpie.querypie.com/api/v1/public/community-licenses` | ← 동일 | ← 동일 | — | +| `PUBLIC_API_KEY` | Encrypted | ← 동일 | ← 동일 | — | | `SLACK_BOT_OAUTH_TOKEN` | Encrypted | Encrypted | Encrypted | — | | `SLACK_CHANNEL_ALERT_WEBSITE_BUSINESS_INQUIRIES` | `C08JNAZDU5A` (#alert-website-business-inquiries) | `C083Y0300M7` (#alert-website-form-submission-testing) | ← 동일 | ← 동일 | | `SLACK_CHANNEL_ALERT_WEBSITE_FORM_SUBMISSION_TESTING` | — | `C083Y0300M7` (#alert-website-form-submission-testing) | ← 동일 | — | **참고:** -- `QUERYPIE_LICENSE_ISSUE_API_*` 미설정 시 라이선스 발급 단계를 skip하고 Slack 알림만 진행한다. +- `DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT` 또는 shared `PUBLIC_API_KEY` 미설정 시 라이선스 발급 단계를 skip하고 Slack 알림만 진행한다. - 테스트/개발용 라이선스 API(`https://licensepie.dev.querypie.io`)는 인터넷 접근 불가로 Vercel 환경에서 사용할 수 없다. Staging/Preview는 Production과 동일한 엔드포인트를 사용하며, Development(로컬)에서만 라이선스 발급 단계를 skip한다. - `SLACK_BOT_OAUTH_TOKEN`은 Vercel 정책상 `development` 환경에 sensitive 타입으로 설정 불가. 로컬 개발 시 Slack 알림은 skip된다. - 앱 코드는 `VERCEL_TARGET_ENV !== "production"`일 때 `SLACK_CHANNEL_ALERT_WEBSITE_FORM_SUBMISSION_TESTING`을 우선 사용하고, 미설정 시 `C083Y0300M7`로 fallback한다. Staging/Preview 입력폼이 production 영업 문의 채널로 전송되지 않게 하는 보호장치다. @@ -173,10 +173,10 @@ Vercel 프로젝트에 설정해야 하는 기준값. 실제 등록값은 Vercel | 변수 | Production | Staging | Preview | Development | |------|------------|---------|---------|-------------| | `DESKPIE_LEAD_API_ENDPOINT` | Encrypted | Encrypted | Encrypted | — | -| `DESKPIE_LEAD_API_KEY` | Encrypted | Encrypted | Encrypted | — | +| `PUBLIC_API_KEY` | Encrypted | Encrypted | Encrypted | — | **참고:** -- `DESKPIE_LEAD_API_ENDPOINT`, `DESKPIE_LEAD_API_KEY` 중 하나라도 없으면 Contact Us의 DeskPie 전송 단계는 skip된다. +- `DESKPIE_LEAD_API_ENDPOINT`, `PUBLIC_API_KEY` 중 하나라도 없으면 Contact Us의 DeskPie 전송 단계는 skip된다. - DeskPie 전송은 best-effort이므로 실패해도 Contact Us 제출 응답은 `{ success: true }`를 유지한다. ### 환경변수 pull (로컬 개발) diff --git a/src/app/api/community-license/route.test.ts b/src/app/api/community-license/route.test.ts index 6b6abf93..b7f9ab88 100644 --- a/src/app/api/community-license/route.test.ts +++ b/src/app/api/community-license/route.test.ts @@ -170,8 +170,8 @@ describe("POST /api/community-license", () => { describe("issueLicense 연동", () => { beforeEach(() => { - vi.stubEnv("QUERYPIE_LICENSE_ISSUE_API_ENDPOINT", "https://license.example.com"); - vi.stubEnv("QUERYPIE_LICENSE_ISSUE_API_KEY", "test-key"); + vi.stubEnv("DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT", "https://license.example.com"); + vi.stubEnv("PUBLIC_API_KEY", "test-key"); }); it("issueLicense API가 실패하면 success:false를 반환하고 Slack을 호출하지 않는다", async () => { @@ -192,7 +192,8 @@ describe("POST /api/community-license", () => { it("issueLicense 성공 후 Slack 메시지를 보내고 success:true를 반환한다", async () => { const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, - json: async () => ({ status: true, errorMessage: "" }), + status: 201, + json: async () => ({ licenseId: "license-id", customerCompanyId: "company-id" }), } as Response); const res = await POST(makeRequest(validBody)); diff --git a/src/app/api/contact-us/route.test.ts b/src/app/api/contact-us/route.test.ts index 3288ca25..aa1c99a9 100644 --- a/src/app/api/contact-us/route.test.ts +++ b/src/app/api/contact-us/route.test.ts @@ -62,7 +62,7 @@ const validBody = { function stubDeskPieEnv() { vi.stubEnv("DESKPIE_LEAD_API_ENDPOINT", "https://api.deskpie.example/api/v1/public/leads"); - vi.stubEnv("DESKPIE_LEAD_API_KEY", "deskpie-key"); + vi.stubEnv("PUBLIC_API_KEY", "deskpie-key"); } function mockDeskPieFetch() { @@ -240,7 +240,7 @@ describe("POST /api/contact-us", () => { }); it("DeskPie API key만 있으면 DeskPie를 호출하지 않는다", async () => { - vi.stubEnv("DESKPIE_LEAD_API_KEY", "deskpie-key"); + vi.stubEnv("PUBLIC_API_KEY", "deskpie-key"); const fetchSpy = vi.spyOn(global, "fetch"); const res = await POST(makeRequest(validBody)); diff --git a/src/app/api/contact-us/route.ts b/src/app/api/contact-us/route.ts index 2c0ad515..68ec293a 100644 --- a/src/app/api/contact-us/route.ts +++ b/src/app/api/contact-us/route.ts @@ -38,7 +38,7 @@ type DeskPieLeadPayload = { async function sendToDeskPieLead(payload: DeskPieLeadPayload): Promise { const endpoint = process.env.DESKPIE_LEAD_API_ENDPOINT; - const apiKey = process.env.DESKPIE_LEAD_API_KEY; + const apiKey = process.env.PUBLIC_API_KEY; if (!endpoint || !apiKey) { return; diff --git a/src/features/community-license/license-service.test.ts b/src/features/community-license/license-service.test.ts index 51e5360d..6d6e929a 100644 --- a/src/features/community-license/license-service.test.ts +++ b/src/features/community-license/license-service.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { issueLicense } from "./license-service"; -const ENDPOINT = "https://license.example.com/license/community"; +const ENDPOINT = "https://api.deskpie.example/api/v1/public/community-licenses"; const API_KEY = "test-api-key"; describe("issueLicense", () => { @@ -20,13 +20,13 @@ describe("issueLicense", () => { }); it("ENDPOINT만 없어도 skip을 반환한다", async () => { - vi.stubEnv("QUERYPIE_LICENSE_ISSUE_API_KEY", API_KEY); + vi.stubEnv("PUBLIC_API_KEY", API_KEY); const result = await issueLicense("TestOrg", "test@example.com"); expect(result).toEqual({ status: "skip" }); }); it("KEY만 없어도 skip을 반환한다", async () => { - vi.stubEnv("QUERYPIE_LICENSE_ISSUE_API_ENDPOINT", ENDPOINT); + vi.stubEnv("DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT", ENDPOINT); const result = await issueLicense("TestOrg", "test@example.com"); expect(result).toEqual({ status: "skip" }); }); @@ -34,8 +34,8 @@ describe("issueLicense", () => { describe("필수 파라미터 검증", () => { beforeEach(() => { - vi.stubEnv("QUERYPIE_LICENSE_ISSUE_API_ENDPOINT", ENDPOINT); - vi.stubEnv("QUERYPIE_LICENSE_ISSUE_API_KEY", API_KEY); + vi.stubEnv("DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT", ENDPOINT); + vi.stubEnv("PUBLIC_API_KEY", API_KEY); }); it("organization이 없으면 throw한다", async () => { @@ -53,14 +53,15 @@ describe("issueLicense", () => { describe("API 호출", () => { beforeEach(() => { - vi.stubEnv("QUERYPIE_LICENSE_ISSUE_API_ENDPOINT", ENDPOINT); - vi.stubEnv("QUERYPIE_LICENSE_ISSUE_API_KEY", API_KEY); + vi.stubEnv("DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT", ENDPOINT); + vi.stubEnv("PUBLIC_API_KEY", API_KEY); }); it("API 성공 시 {status:'success'}를 반환한다", async () => { vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, - json: async () => ({ status: true, errorMessage: "" }), + status: 201, + json: async () => ({ licenseId: "license-id", customerCompanyId: "company-id" }), } as Response); const result = await issueLicense("TestOrg", "test@example.com"); @@ -70,7 +71,8 @@ describe("issueLicense", () => { it("올바른 엔드포인트와 헤더로 요청한다", async () => { const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, - json: async () => ({ status: true, errorMessage: "" }), + status: 201, + json: async () => ({ licenseId: "license-id", customerCompanyId: "company-id" }), } as Response); await issueLicense("TestOrg", "test@example.com"); @@ -97,15 +99,16 @@ describe("issueLicense", () => { ); }); - it("응답 status가 false이면 errorMessage로 throw한다", async () => { + it("201 응답 body와 무관하게 성공한다", async () => { vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, - json: async () => ({ status: false, errorMessage: "License limit exceeded" }), + status: 201, + json: async () => ({ licenseId: "license-id", customerCompanyId: "company-id" }), } as Response); - await expect(issueLicense("TestOrg", "test@example.com")).rejects.toThrow( - "License limit exceeded", - ); + await expect(issueLicense("TestOrg", "test@example.com")).resolves.toEqual({ + status: "success", + }); }); }); }); diff --git a/src/features/community-license/license-service.ts b/src/features/community-license/license-service.ts index 87c86a68..797e9b61 100644 --- a/src/features/community-license/license-service.ts +++ b/src/features/community-license/license-service.ts @@ -1,8 +1,3 @@ -type APIResponse = { - status: boolean; - errorMessage: string; -}; - /** * @param organization 회사명 * @param requestedBy 요청자 이메일 @@ -14,18 +9,18 @@ export const issueLicense = async (organization?: string, requestedBy?: string) } if ( - !process.env.QUERYPIE_LICENSE_ISSUE_API_ENDPOINT || - !process.env.QUERYPIE_LICENSE_ISSUE_API_KEY + !process.env.DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT || + !process.env.PUBLIC_API_KEY ) { console.warn("[community-license] license issue: skipped (env not set)"); return { status: "skip" }; } - const response = await fetch(process.env.QUERYPIE_LICENSE_ISSUE_API_ENDPOINT, { + const response = await fetch(process.env.DESKPIE_COMMUNITY_LICENSE_API_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", - "X-API-KEY": process.env.QUERYPIE_LICENSE_ISSUE_API_KEY, + "X-API-KEY": process.env.PUBLIC_API_KEY, }, body: JSON.stringify({ organization, requestedBy }), }); @@ -34,12 +29,6 @@ export const issueLicense = async (organization?: string, requestedBy?: string) throw new Error(`Failed to issue license: ${response.status}`); } - const data = (await response.json()) as APIResponse; - - if (!data.status) { - throw new Error(data.errorMessage); - } - console.info("[community-license] license issue: success"); return { status: "success" }; } catch (error) {