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:
Alejandro Gutiérrez
2026-02-24 22:21:40 +00:00
commit 502fd3e435
5 changed files with 140 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

8
Dockerfile Normal file
View 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
View 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
View 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
View 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 }