From 502fd3e435f5b2b6bf41d29c30ae8573e010631e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:21:40 +0000 Subject: [PATCH] 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 --- .gitignore | 1 + Dockerfile | 8 +++++ bun.lock | 18 ++++++++++ package.json | 13 +++++++ src/index.ts | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/index.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6bdca3d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..a1db592 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd87107 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..14740b0 --- /dev/null +++ b/src/index.ts @@ -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 }