← back to Trademarks Copyright

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

110 lines

import { NextRequest, NextResponse } from "next/server";
import { query } from "@/lib/db";
import { activeSubscribersFor, renderDropHTML } from "@/lib/drops";
import { sendEmail, currentBackend } from "@/lib/email";

export const runtime = "nodejs";
export const maxDuration = 300;

/**
 * POST /api/drops/send  { dropId?: number, dryRun?: boolean, testEmail?: string }
 *   - default: send drop with matching dropId (or today's latest) to all active subs that haven't received it
 *   - testEmail: deliver only to that address regardless
 *   - dryRun:   compose but don't call the backend
 */
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 dryRun = !!body.dryRun;
  const testEmail = body.testEmail ? String(body.testEmail) : null;
  let dropId: number | null = Number.isFinite(body.dropId) ? body.dropId : null;

  const origin = req.nextUrl.origin;

  if (!dropId) {
    const { rows } = await query<{ id: number }>(
      `SELECT id FROM drops WHERE drop_date <= CURRENT_DATE ORDER BY drop_date DESC LIMIT 1`
    );
    if (!rows.length) return NextResponse.json({ error: "no drops exist yet — compose one first" }, { status: 404 });
    dropId = rows[0].id;
  }

  const subs = testEmail
    ? [{ id: 0, email: testEmail, name: null, token: "preview", tier: "pro" }]
    : await activeSubscribersFor(dropId);

  const backend = currentBackend();
  const results: { email: string; ok: boolean; error?: string; id?: string }[] = [];

  for (const s of subs) {
    // Pre-create the delivery row so the open-tracking pixel has a stable ID.
    let deliveryId: number | undefined;
    if (!testEmail && !dryRun) {
      const { rows } = await query<{ id: number }>(
        `INSERT INTO deliveries (drop_id, subscriber_id, delivered_via)
         VALUES ($1, $2, 'pending') ON CONFLICT (drop_id, subscriber_id)
         DO UPDATE SET delivered_via = EXCLUDED.delivered_via
         RETURNING id`,
        [dropId, s.id]
      );
      deliveryId = rows[0]?.id;
    }

    const { subject, html, text } = await renderDropHTML({
      dropId,
      tier: s.tier as "trial" | "standard" | "pro" | "comp",
      subscriberToken: s.token,
      appBaseUrl: origin,
      deliveryId,
    });

    if (dryRun) {
      results.push({ email: s.email, ok: true, id: "(dry-run)" });
      continue;
    }

    const r = await sendEmail({ to: s.email, subject, html, text, unsubscribeToken: s.token });
    results.push({ email: s.email, ok: r.ok, error: r.error, id: r.id });

    if (!testEmail && r.ok && deliveryId) {
      await query(
        `UPDATE deliveries SET delivered_via = $1, sent_at = NOW() WHERE id = $2`,
        [r.via, deliveryId]
      );
      // Decrement trial counter and auto-flip to trial_expired when it hits 0.
      await query(
        `UPDATE subscribers
         SET last_delivered_at = NOW(),
             trial_drops_left = CASE WHEN tier = 'trial' THEN GREATEST(trial_drops_left - 1, 0) ELSE trial_drops_left END,
             status = CASE
               WHEN tier = 'trial' AND GREATEST(trial_drops_left - 1, 0) = 0 THEN 'trial_expired'
               ELSE status
             END
         WHERE id = $1`,
        [s.id]
      );
    } else if (!testEmail && !r.ok && deliveryId) {
      // Mark as bounced so we don't retry indefinitely.
      await query(
        `UPDATE deliveries SET bounced = TRUE, error = $1 WHERE id = $2`,
        [r.error ?? "unknown", deliveryId]
      );
    }
  }

  if (!testEmail && !dryRun && results.some((r) => r.ok)) {
    await query(`UPDATE drops SET status = 'sent', sent_at = COALESCE(sent_at, NOW()) WHERE id = $1`, [dropId]);
  }

  return NextResponse.json({
    ok: true,
    dropId,
    backend,
    sent: results.filter((r) => r.ok).length,
    failed: results.filter((r) => !r.ok).length,
    results,
  });
}