feat: leads capture API with PostgreSQL backend
Bun + Hono API service for capturing email subscribers across multiple projects. Supports subscribe/unsubscribe, admin stats, and deduplication per email+project pair. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
8
Dockerfile
Normal file
8
Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM oven/bun:1.3-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
COPY src ./src
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3400
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
18
bun.lock
Normal file
18
bun.lock
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "leads-api",
|
||||
"dependencies": {
|
||||
"hono": "^4",
|
||||
"postgres": "^3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="],
|
||||
|
||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||
}
|
||||
}
|
||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "leads-api",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"dev": "bun --watch run src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4",
|
||||
"postgres": "^3"
|
||||
}
|
||||
}
|
||||
100
src/index.ts
Normal file
100
src/index.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Hono } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
import postgres from "postgres"
|
||||
|
||||
const sql = postgres(Bun.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5433/leads")
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.use("/*", cors({
|
||||
origin: (origin) => origin, // allow all origins (project sites)
|
||||
allowMethods: ["POST", "GET", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type"],
|
||||
}))
|
||||
|
||||
app.get("/health", (c) => c.json({ ok: true }))
|
||||
|
||||
// Subscribe endpoint
|
||||
app.post("/subscribe", async (c) => {
|
||||
const body = await c.req.json()
|
||||
const email = body.email?.trim()?.toLowerCase()
|
||||
const name = body.name?.trim() || null
|
||||
const project = body.project?.trim()
|
||||
const interests = body.interests || []
|
||||
const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || ""
|
||||
const referrer = body.referrer || c.req.header("referer") || ""
|
||||
|
||||
if (!email || !project) {
|
||||
return c.json({ error: "email and project are required" }, 400)
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return c.json({ error: "invalid email" }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO subscribers (email, name, project, interests, ip, referrer)
|
||||
VALUES (${email}, ${name}, ${project}, ${interests}, ${ip}, ${referrer})
|
||||
ON CONFLICT (email, project) DO UPDATE SET
|
||||
name = COALESCE(EXCLUDED.name, subscribers.name),
|
||||
interests = EXCLUDED.interests,
|
||||
unsubscribed_at = NULL
|
||||
`
|
||||
return c.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
console.error("Subscribe error:", err.message)
|
||||
return c.json({ error: "failed to subscribe" }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// Unsubscribe endpoint
|
||||
app.post("/unsubscribe", async (c) => {
|
||||
const body = await c.req.json()
|
||||
const email = body.email?.trim()?.toLowerCase()
|
||||
const project = body.project?.trim()
|
||||
|
||||
if (!email || !project) {
|
||||
return c.json({ error: "email and project are required" }, 400)
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE subscribers SET unsubscribed_at = NOW()
|
||||
WHERE email = ${email} AND project = ${project}
|
||||
`
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
// Admin: list subscribers (protected by simple token)
|
||||
app.get("/admin/subscribers", async (c) => {
|
||||
const token = c.req.header("authorization")?.replace("Bearer ", "")
|
||||
if (token !== Bun.env.ADMIN_TOKEN) {
|
||||
return c.json({ error: "unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const project = c.req.query("project")
|
||||
const rows = project
|
||||
? await sql`SELECT id, email, name, project, interests, subscribed_at, confirmed, unsubscribed_at FROM subscribers WHERE project = ${project} AND unsubscribed_at IS NULL ORDER BY subscribed_at DESC`
|
||||
: await sql`SELECT id, email, name, project, interests, subscribed_at, confirmed, unsubscribed_at FROM subscribers WHERE unsubscribed_at IS NULL ORDER BY subscribed_at DESC`
|
||||
|
||||
return c.json({ count: rows.length, subscribers: rows })
|
||||
})
|
||||
|
||||
// Admin: stats
|
||||
app.get("/admin/stats", async (c) => {
|
||||
const token = c.req.header("authorization")?.replace("Bearer ", "")
|
||||
if (token !== Bun.env.ADMIN_TOKEN) {
|
||||
return c.json({ error: "unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const stats = await sql`
|
||||
SELECT project, COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE unsubscribed_at IS NULL) as active
|
||||
FROM subscribers GROUP BY project ORDER BY total DESC
|
||||
`
|
||||
return c.json({ projects: stats })
|
||||
})
|
||||
|
||||
const port = parseInt(Bun.env.PORT || "3400")
|
||||
console.log(`Leads API listening on :${port}`)
|
||||
export default { port, fetch: app.fetch }
|
||||
Reference in New Issue
Block a user