← 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.",
});
}