Skip to content

Commit 1d154c6

Browse files
committed
feat(contact): route submissions to github issues via cloudflare worker
1 parent dd932ca commit 1d154c6

7 files changed

Lines changed: 267 additions & 11 deletions

File tree

app/api/contact/route.ts

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,51 @@
11
import { NextResponse } from "next/server";
2-
import { ContactFormSchema } from "@/lib/contact/schema";
2+
import { ContactFormSchema, type ContactFormValues } from "@/lib/contact/schema";
33

4-
// TODO(oma-deferred): integrate Resend + Cloudflare Turnstile in backend stage.
5-
// For now, this handler validates payload and acknowledges. Wire real send when
6-
// RESEND_API_KEY is provisioned.
4+
interface GithubIssuePayload {
5+
title: string;
6+
body: string;
7+
labels?: string[];
8+
}
9+
10+
function buildIssueTitle(message: string, email: string): string {
11+
const oneLine = message.replace(/\s+/g, " ").trim();
12+
const snippet = oneLine.slice(0, 60);
13+
const truncated = oneLine.length > 60 ? `${snippet}…` : snippet;
14+
return `[Contact] ${truncated || email}`;
15+
}
16+
17+
function buildIssueBody(data: Pick<ContactFormValues, "email" | "message">): string {
18+
return [
19+
`**From:** ${data.email}`,
20+
`**Received:** ${new Date().toISOString()}`,
21+
"",
22+
"---",
23+
"",
24+
data.message,
25+
].join("\n");
26+
}
27+
28+
async function createGithubIssue(
29+
repo: string,
30+
token: string,
31+
payload: GithubIssuePayload,
32+
): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
33+
const res = await fetch(`https://api.github.com/repos/${repo}/issues`, {
34+
method: "POST",
35+
headers: {
36+
Authorization: `Bearer ${token}`,
37+
Accept: "application/vnd.github+json",
38+
"X-GitHub-Api-Version": "2022-11-28",
39+
"User-Agent": "firstfluke-contact-form",
40+
},
41+
body: JSON.stringify(payload),
42+
});
43+
if (!res.ok) {
44+
const error = await res.text();
45+
return { ok: false, status: res.status, error };
46+
}
47+
return { ok: true };
48+
}
749

850
export async function POST(request: Request) {
951
let payload: unknown;
@@ -24,21 +66,39 @@ export async function POST(request: Request) {
2466
);
2567
}
2668

27-
// Honeypot tripped — silently drop, return ok=true to fool bots.
2869
if (parsed.data._hp && parsed.data._hp.length > 0) {
2970
return NextResponse.json({ ok: true });
3071
}
3172

32-
const apiKey = process.env.RESEND_API_KEY;
33-
if (!apiKey) {
34-
// Fallback path — log and acknowledge so demo flows work without secrets.
35-
console.info("[contact] RESEND_API_KEY missing. Payload acknowledged:", {
73+
const token = process.env.GITHUB_ISSUE_TOKEN;
74+
const repo = process.env.GITHUB_ISSUE_REPO;
75+
76+
if (!token || !repo) {
77+
// TODO(oma-deferred): integrate GitHub Issues when GITHUB_ISSUE_TOKEN/GITHUB_ISSUE_REPO is provisioned.
78+
console.info("[contact] GitHub credentials missing. Payload acknowledged:", {
3679
email: parsed.data.email,
3780
messageLength: parsed.data.message.length,
3881
});
3982
return NextResponse.json({ ok: true });
4083
}
4184

42-
// Real send path — implemented by backend stage.
85+
const result = await createGithubIssue(repo, token, {
86+
title: buildIssueTitle(parsed.data.message, parsed.data.email),
87+
body: buildIssueBody(parsed.data),
88+
labels: ["contact"],
89+
});
90+
91+
if (!result.ok) {
92+
console.error(
93+
"[contact] github issue create failed:",
94+
result.status,
95+
result.error,
96+
);
97+
return NextResponse.json(
98+
{ ok: false, error: "send_failed" },
99+
{ status: 502 },
100+
);
101+
}
102+
43103
return NextResponse.json({ ok: true });
44104
}

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@
3030
".next/dev/types/**/*.ts",
3131
"**/*.mts"
3232
],
33-
"exclude": ["node_modules"]
33+
"exclude": ["node_modules", "worker"]
3434
}

worker/package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "firstfluke-contact-worker",
3+
"private": true,
4+
"version": "0.0.1",
5+
"scripts": {
6+
"dev": "wrangler dev",
7+
"deploy": "wrangler deploy",
8+
"tail": "wrangler tail",
9+
"typecheck": "tsc --noEmit"
10+
},
11+
"dependencies": {
12+
"zod": "^4.4.3"
13+
},
14+
"devDependencies": {
15+
"@cloudflare/workers-types": "^4.20250101.0",
16+
"typescript": "^5.6.0",
17+
"wrangler": "^4.0.0"
18+
}
19+
}

worker/src/index.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { ContactFormSchema, type ContactFormValues } from "./schema";
2+
3+
interface Env {
4+
GITHUB_ISSUE_TOKEN: string;
5+
GITHUB_ISSUE_REPO: string;
6+
}
7+
8+
interface GithubIssuePayload {
9+
title: string;
10+
body: string;
11+
labels?: string[];
12+
}
13+
14+
function jsonResponse(data: unknown, status: number): Response {
15+
return new Response(JSON.stringify(data), {
16+
status,
17+
headers: { "Content-Type": "application/json" },
18+
});
19+
}
20+
21+
function buildIssueTitle(message: string, email: string): string {
22+
const oneLine = message.replace(/\s+/g, " ").trim();
23+
const snippet = oneLine.slice(0, 60);
24+
const truncated = oneLine.length > 60 ? `${snippet}…` : snippet;
25+
return `[Contact] ${truncated || email}`;
26+
}
27+
28+
function buildIssueBody(
29+
data: Pick<ContactFormValues, "email" | "message">,
30+
): string {
31+
return [
32+
`**From:** ${data.email}`,
33+
`**Received:** ${new Date().toISOString()}`,
34+
"",
35+
"---",
36+
"",
37+
data.message,
38+
].join("\n");
39+
}
40+
41+
async function createGithubIssue(
42+
env: Env,
43+
payload: GithubIssuePayload,
44+
): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
45+
const res = await fetch(
46+
`https://api.github.com/repos/${env.GITHUB_ISSUE_REPO}/issues`,
47+
{
48+
method: "POST",
49+
headers: {
50+
Authorization: `Bearer ${env.GITHUB_ISSUE_TOKEN}`,
51+
Accept: "application/vnd.github+json",
52+
"X-GitHub-Api-Version": "2022-11-28",
53+
"User-Agent": "firstfluke-contact-worker",
54+
},
55+
body: JSON.stringify(payload),
56+
},
57+
);
58+
if (!res.ok) {
59+
const error = await res.text();
60+
return { ok: false, status: res.status, error };
61+
}
62+
return { ok: true };
63+
}
64+
65+
export default {
66+
async fetch(request: Request, env: Env): Promise<Response> {
67+
if (request.method !== "POST") {
68+
return new Response(null, { status: 405 });
69+
}
70+
71+
let payload: unknown;
72+
try {
73+
payload = await request.json();
74+
} catch {
75+
return jsonResponse({ ok: false, error: "validation" }, 400);
76+
}
77+
78+
const parsed = ContactFormSchema.safeParse(payload);
79+
if (!parsed.success) {
80+
return jsonResponse(
81+
{
82+
ok: false,
83+
error: "validation",
84+
fields: parsed.error.flatten().fieldErrors,
85+
},
86+
400,
87+
);
88+
}
89+
90+
if (parsed.data._hp && parsed.data._hp.length > 0) {
91+
return jsonResponse({ ok: true }, 200);
92+
}
93+
94+
if (!env.GITHUB_ISSUE_TOKEN || !env.GITHUB_ISSUE_REPO) {
95+
// TODO(oma-deferred): set GITHUB_ISSUE_TOKEN and GITHUB_ISSUE_REPO via `wrangler secret put`.
96+
console.info(
97+
"[contact] GitHub credentials missing. Payload acknowledged:",
98+
{
99+
email: parsed.data.email,
100+
messageLength: parsed.data.message.length,
101+
},
102+
);
103+
return jsonResponse({ ok: true }, 200);
104+
}
105+
106+
const result = await createGithubIssue(env, {
107+
title: buildIssueTitle(parsed.data.message, parsed.data.email),
108+
body: buildIssueBody(parsed.data),
109+
labels: ["contact"],
110+
});
111+
112+
if (!result.ok) {
113+
console.error(
114+
"[contact] github issue create failed:",
115+
result.status,
116+
result.error,
117+
);
118+
return jsonResponse({ ok: false, error: "send_failed" }, 502);
119+
}
120+
121+
return jsonResponse({ ok: true }, 200);
122+
},
123+
} satisfies ExportedHandler<Env>;

worker/src/schema.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { z } from "zod";
2+
3+
export const ContactFormSchema = z.object({
4+
email: z
5+
.string()
6+
.trim()
7+
.min(1, "이메일을 입력해주세요.")
8+
.email("올바른 이메일 형식이 아니에요."),
9+
message: z
10+
.string()
11+
.trim()
12+
.min(1, "메시지를 입력해주세요.")
13+
.max(5000, "메시지가 너무 길어요."),
14+
agree: z.literal(true, {
15+
error: "개인정보 수집·이용에 동의해주세요.",
16+
}),
17+
turnstileToken: z.string().optional(),
18+
_hp: z.string().optional(),
19+
});
20+
21+
export type ContactFormValues = z.infer<typeof ContactFormSchema>;

worker/tsconfig.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "ES2022",
5+
"moduleResolution": "bundler",
6+
"lib": ["ES2022"],
7+
"types": ["@cloudflare/workers-types"],
8+
"strict": true,
9+
"noEmit": true,
10+
"esModuleInterop": true,
11+
"isolatedModules": true,
12+
"resolveJsonModule": true,
13+
"skipLibCheck": true
14+
},
15+
"include": ["src/**/*"]
16+
}

worker/wrangler.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Cloudflare Worker — firstfluke contact → GitHub Issues
2+
#
3+
# Setup steps (run inside this directory):
4+
# 1) pnpm install
5+
# 2) pnpm wrangler login
6+
# 3) pnpm wrangler secret put GITHUB_ISSUE_TOKEN # Fine-grained PAT, Issues: read+write on the target repo
7+
# 4) pnpm wrangler secret put GITHUB_ISSUE_REPO # e.g. "firstfluke/contact-inbox"
8+
# 5) Uncomment the [[routes]] block below and replace the domain.
9+
# 6) pnpm deploy
10+
11+
name = "firstfluke-contact"
12+
main = "src/index.ts"
13+
compatibility_date = "2025-05-01"
14+
15+
# [[routes]]
16+
# pattern = "firstfluke.example.com/api/contact*"
17+
# zone_name = "firstfluke.example.com"

0 commit comments

Comments
 (0)