Skip to content
Open
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
6 changes: 3 additions & 3 deletions docs/reference/community-license.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`

Expand All @@ -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 |
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/contact-us-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }`.

Expand Down Expand Up @@ -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 }` |
Expand All @@ -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를 공유한다.

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/lead-capture-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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에 포함 |
Expand Down Expand Up @@ -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 환경변수가 없거나 전송에 실패해도 제출 성공은 유지한다.

Expand Down
7 changes: 4 additions & 3 deletions docs/reference/staging-lead-form.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 5 additions & 5 deletions docs/reference/vercel-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 영업 문의 채널로 전송되지 않게 하는 보호장치다.
Expand All @@ -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 (로컬 개발)
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/community-license/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/contact-us/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/contact-us/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type DeskPieLeadPayload = {

async function sendToDeskPieLead(payload: DeskPieLeadPayload): Promise<void> {
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;
Expand Down
31 changes: 17 additions & 14 deletions src/features/community-license/license-service.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -20,22 +20,22 @@ 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" });
});
});

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 () => {
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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",
});
});
});
});
19 changes: 4 additions & 15 deletions src/features/community-license/license-service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
type APIResponse = {
status: boolean;
errorMessage: string;
};

/**
* @param organization 회사명
* @param requestedBy 요청자 이메일
Expand All @@ -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 }),
});
Expand All @@ -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) {
Expand Down
Loading