← back to Trademarks Copyright

src/app/api/drops/signup/route.ts

150 lines

import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "node:crypto";
import { query } from "@/lib/db";
import { checkRateLimit, isDisposableEmail } from "@/lib/rateLimit";
import { renderDropHTML } from "@/lib/drops";
import { sendEmail } from "@/lib/email";

export async function POST(req: NextRequest) {
  const body = await req.json().catch(() => ({}));
  if (!body || typeof body !== "object" || Array.isArray(body)) {
    return NextResponse.json({ error: "json object body required" }, { status: 400 });
  }
  const email = String(body.email || "").trim().toLowerCase();
  const name = body.name ? String(body.name).trim().slice(0, 120) : null;
  const ref = body.ref ? String(body.ref).trim().slice(0, 80) : null;

  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return NextResponse.json({ error: "valid email required" }, { status: 400 });
  }
  if (email.length > 254) {
    return NextResponse.json({ error: "email too long" }, { status: 400 });
  }
  if (isDisposableEmail(email)) {
    return NextResponse.json({ error: "please use a real email address — disposable addresses aren't accepted" }, { status: 400 });
  }

  // Rate limit by IP + by email.
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
    ?? req.headers.get("x-real-ip")
    ?? "unknown";
  const ipCheck = await checkRateLimit(ip, "signup-ip", { max: 5, windowMinutes: 60 });
  if (!ipCheck.allowed) {
    return NextResponse.json({ error: "too many signups from this network — try again later" }, { status: 429 });
  }
  const emailCheck = await checkRateLimit(email, "signup-email", { max: 3, windowMinutes: 1440 });
  if (!emailCheck.allowed) {
    return NextResponse.json({ error: "this email has been signed up multiple times recently" }, { status: 429 });
  }

  const token = randomBytes(18).toString("base64url");

  const { rows } = await query<{ id: number; token: string; tier: string }>(
    `INSERT INTO subscribers (email, name, token, tier, trial_drops_left, notes)
     VALUES ($1, $2, $3, 'trial', 3, $4)
     ON CONFLICT (email) DO UPDATE SET
       name = COALESCE(EXCLUDED.name, subscribers.name),
       status = CASE WHEN subscribers.status = 'cancelled' THEN 'active' ELSE subscribers.status END
     RETURNING id, token, tier`,
    [email, name, token, ref ? `referred by ${ref}` : null]
  );

  // Referral tracking — all inside a single transaction to prevent double-reward races.
  if (ref) {
    try {
      const client = await (await import("@/lib/db")).pool.connect();
      try {
        await client.query("BEGIN");
        const { rows: refRows } = await client.query<{ id: number }>(
          `SELECT id FROM subscribers WHERE token = $1 AND status = 'active' FOR UPDATE`,
          [ref]
        );
        if (refRows.length) {
          await client.query(
            `INSERT INTO referrals (referrer_token, referred_email, referred_sub_id)
             VALUES ($1, $2, $3)
             ON CONFLICT (referrer_token, referred_email) DO NOTHING`,
            [ref, email, rows[0].id]
          );
          // Atomically claim exactly 3 unrewarded referrals; only grant the reward
          // if we got all 3 back. No count-then-update race.
          const { rows: claimed } = await client.query<{ id: number }>(
            `WITH claim AS (
               SELECT id FROM referrals
               WHERE referrer_token = $1 AND reward_given = FALSE
               ORDER BY created_at ASC
               LIMIT 3
               FOR UPDATE
             )
             UPDATE referrals SET reward_given = TRUE
             WHERE id IN (SELECT id FROM claim)
             RETURNING id`,
            [ref]
          );
          if (claimed.length === 3) {
            await client.query(
              `UPDATE subscribers SET trial_drops_left = trial_drops_left + 30 WHERE token = $1`,
              [ref]
            );
          } else if (claimed.length > 0) {
            // Not enough for a reward yet — unclaim them.
            await client.query(
              `UPDATE referrals SET reward_given = FALSE WHERE id = ANY($1::int[])`,
              [claimed.map((r) => r.id)]
            );
          }
        }
        await client.query("COMMIT");
      } catch (e) {
        await client.query("ROLLBACK");
        console.warn("referral txn rollback:", e);
      } finally {
        client.release();
      }
    } catch { /* silent — ref-invalid shouldn't block signup */ }
  }

  // Welcome drop: immediately send the most recent published drop so new subscribers
  // get value on day zero, not day one.
  try {
    const { rows: latest } = await query<{ id: number }>(
      `SELECT id FROM drops WHERE status IN ('published','sent') ORDER BY drop_date DESC LIMIT 1`
    );
    if (latest.length) {
      const origin = req.nextUrl.origin;
      const { rows: delRows } = await query<{ id: number }>(
        `INSERT INTO deliveries (drop_id, subscriber_id, delivered_via)
         VALUES ($1, $2, 'welcome-pending')
         ON CONFLICT (drop_id, subscriber_id) DO UPDATE SET delivered_via = EXCLUDED.delivered_via
         RETURNING id`,
        [latest[0].id, rows[0].id]
      );
      const { subject, html, text } = await renderDropHTML({
        dropId: latest[0].id,
        tier: rows[0].tier as "trial",
        subscriberToken: rows[0].token,
        appBaseUrl: origin,
        deliveryId: delRows[0]?.id,
      });
      const welcomeSubject = `Welcome to Drops — ${subject}`;
      const r = await sendEmail({ to: email, subject: welcomeSubject, html, text, unsubscribeToken: rows[0].token });
      if (r.ok && delRows[0]?.id) {
        await query(
          `UPDATE deliveries SET delivered_via = $1, sent_at = NOW() WHERE id = $2`,
          [`welcome-${r.via}`, delRows[0].id]
        );
        await query(
          `UPDATE subscribers SET last_delivered_at = NOW(), trial_drops_left = GREATEST(trial_drops_left - 1, 0) WHERE id = $1`,
          [rows[0].id]
        );
      }
    }
  } catch (e) { console.warn("welcome-drop failed:", e); }

  return NextResponse.json({
    ok: true,
    subscriber: { id: rows[0].id, token: rows[0].token, tier: rows[0].tier },
    next: "Welcome drop sent. Next issue lands tomorrow at 9am PT.",
  });
}