import { NextRequest, NextResponse } from "next/server"; import crypto from "node:crypto"; import { db, users, recipes, comments, ratings, userFollows, favorites, eq, and, gte, desc, count, sql, } from "@epicure/db"; import { sendEmail, weeklyDigestHtml } from "@/lib/email"; // Internal cron endpoint — triggered by the `digest-cron` container on a weekly // schedule (see compose.prod.yml). Not part of the public API surface; // protected by a shared secret rather than user auth. // // Computes, for every user: new followers / new comments / new ratings on // their recipes in the last 7 days, plus a site-wide top-3 trending list, and // emails a summary. Sends to all users (all users have a non-null email) — // there's no per-user opt-out preference yet; out of scope for this pass. const CHUNK_SIZE = 20; function isAuthorized(req: NextRequest): boolean { const secret = process.env["CRON_SECRET"]; if (!secret) return false; const header = req.headers.get("authorization"); if (!header?.startsWith("Bearer ")) return false; const provided = header.slice("Bearer ".length); const a = Buffer.from(provided); const b = Buffer.from(secret); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } function chunk(arr: T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } export async function POST(req: NextRequest) { if (!isAuthorized(req)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"; const [allUsers, followerRows, commentRows, ratingRows, trending] = await Promise.all([ db.select({ id: users.id, email: users.email }).from(users), db .select({ userId: userFollows.followingId, n: count() }) .from(userFollows) .where(gte(userFollows.createdAt, weekAgo)) .groupBy(userFollows.followingId), db .select({ userId: recipes.authorId, n: count() }) .from(comments) .innerJoin(recipes, eq(comments.recipeId, recipes.id)) .where(gte(comments.createdAt, weekAgo)) .groupBy(recipes.authorId), db .select({ userId: recipes.authorId, n: count() }) .from(ratings) .innerJoin(recipes, eq(ratings.recipeId, recipes.id)) .where(gte(ratings.createdAt, weekAgo)) .groupBy(recipes.authorId), db .select({ id: recipes.id, title: recipes.title, favoriteCount: sql`cast(count(${favorites.recipeId}) as int)`, }) .from(recipes) .leftJoin( favorites, and(eq(favorites.recipeId, recipes.id), gte(favorites.createdAt, weekAgo)) ) .where(eq(recipes.visibility, "public")) .groupBy(recipes.id) .orderBy(desc(sql`count(${favorites.recipeId})`), desc(recipes.createdAt)) .limit(3), ]); const followerMap = new Map(followerRows.map((r) => [r.userId, r.n])); const commentMap = new Map(commentRows.map((r) => [r.userId, r.n])); const ratingMap = new Map(ratingRows.map((r) => [r.userId, r.n])); const trendingList = trending.map((r) => ({ id: r.id, title: r.title })); let sent = 0; let failed = 0; for (const batch of chunk(allUsers, CHUNK_SIZE)) { const results = await Promise.allSettled( batch.map((user) => { const newFollowers = followerMap.get(user.id) ?? 0; const newComments = commentMap.get(user.id) ?? 0; const newRatings = ratingMap.get(user.id) ?? 0; return sendEmail({ to: user.email, subject: "Your weekly digest — Epicure", html: weeklyDigestHtml({ newFollowers, newComments, newRatings, trending: trendingList, baseUrl, }), }); }) ); for (const r of results) { if (r.status === "fulfilled") sent++; else failed++; } } return NextResponse.json({ ok: true, totalUsers: allUsers.length, sent, failed }); }