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