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