Files
avbeat-com/supabase/functions/send-weekly-digest/index.ts
Ryan Salazar fa94e92af5 chore: purge all rocket.new references from source
- Replace img.rocket.new mock URLs in authors/[slug] + FeaturedBento
  with /assets/images/article-placeholder.svg (live DB data drives
  these pages now; the static maps are fallback only).
- Replace broadcastb5322.builtwithrocket.new with broadcastbeat.com
  in admin API routes, news detail, forum layout, and the 4 Supabase
  edge functions that build outbound email links.
- Swap rocket-hosted JSON-LD logo URL in root layout for the local
  /assets/images/logo.png we already ship.
- Drop img.rocket.new from the Next.js image-hosts allowlist;
  add supabase.onsethost.com so storage proxy URLs can render.

The DB has had zero rocket.new image refs for weeks; this finishes
the job in the codebase so no page on broadcastbeat.com can render
a rocket-hosted asset anymore.
2026-05-20 06:14:02 +00:00

382 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { serve } from "https://deno.land/std@0.192.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
declare const Deno: {
env: {
get(key: string): string | undefined;
};
};
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY") ?? "";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
function buildDigestHtml(params: {
userName: string;
userEmail: string;
replies: Array<{ thread_title: string; reply_body: string; link: string; created_at: string }>;
upvotes: Array<{ target_type: string; title: string; link: string; count: number }>;
mentions: Array<{ thread_title: string; actor_name: string; link: string; created_at: string }>;
weekLabel: string;
}): string {
const { userName, replies, upvotes, mentions, weekLabel } = params;
const hasContent = replies.length > 0 || upvotes.length > 0 || mentions.length > 0;
const repliesHtml = replies.length > 0
? `
<tr>
<td style="padding: 0 0 28px 0;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 0 0 14px 0; border-bottom: 2px solid #3b82f6;">
<span style="font-family: Georgia, serif; font-size: 13px; font-weight: bold; text-transform: uppercase; letter-spacing: 2px; color: #3b82f6;">Thread Replies</span>
</td>
</tr>
${replies.map(r => `
<tr>
<td style="padding: 14px 0; border-bottom: 1px solid #1e1e1e;">
<a href="${r.link}" style="font-family: Georgia, serif; font-size: 15px; font-weight: bold; color: #e0e0e0; text-decoration: none; display: block; margin-bottom: 4px;">${r.thread_title}</a>
<p style="font-family: Arial, sans-serif; font-size: 13px; color: #888; margin: 0 0 4px 0; line-height: 1.5;">${r.reply_body.length > 120 ? r.reply_body.substring(0, 120) + "…" : r.reply_body}</p>
<span style="font-family: Arial, sans-serif; font-size: 11px; color: #555;">${new Date(r.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>
</td>
</tr>`).join("")}
</table>
</td>
</tr>`
: "";
const upvotesHtml = upvotes.length > 0
? `
<tr>
<td style="padding: 0 0 28px 0;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 0 0 14px 0; border-bottom: 2px solid #22c55e;">
<span style="font-family: Georgia, serif; font-size: 13px; font-weight: bold; text-transform: uppercase; letter-spacing: 2px; color: #22c55e;">Upvotes Received</span>
</td>
</tr>
${upvotes.map(u => `
<tr>
<td style="padding: 14px 0; border-bottom: 1px solid #1e1e1e;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<a href="${u.link}" style="font-family: Georgia, serif; font-size: 15px; font-weight: bold; color: #e0e0e0; text-decoration: none;">${u.title}</a>
<p style="font-family: Arial, sans-serif; font-size: 12px; color: #888; margin: 4px 0 0 0; text-transform: capitalize;">${u.target_type}</p>
</td>
<td style="text-align: right; white-space: nowrap;">
<span style="font-family: Georgia, serif; font-size: 22px; font-weight: bold; color: #22c55e;">+${u.count}</span>
<p style="font-family: Arial, sans-serif; font-size: 11px; color: #555; margin: 0; text-align: right;">upvotes</p>
</td>
</tr>
</table>
</td>
</tr>`).join("")}
</table>
</td>
</tr>`
: "";
const mentionsHtml = mentions.length > 0
? `
<tr>
<td style="padding: 0 0 28px 0;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 0 0 14px 0; border-bottom: 2px solid #f59e0b;">
<span style="font-family: Georgia, serif; font-size: 13px; font-weight: bold; text-transform: uppercase; letter-spacing: 2px; color: #f59e0b;">Mentions</span>
</td>
</tr>
${mentions.map(m => `
<tr>
<td style="padding: 14px 0; border-bottom: 1px solid #1e1e1e;">
<a href="${m.link}" style="font-family: Georgia, serif; font-size: 15px; font-weight: bold; color: #e0e0e0; text-decoration: none; display: block; margin-bottom: 4px;">${m.thread_title}</a>
<p style="font-family: Arial, sans-serif; font-size: 13px; color: #888; margin: 0 0 4px 0;"><strong style="color: #f59e0b;">${m.actor_name}</strong> mentioned you</p>
<span style="font-family: Arial, sans-serif; font-size: 11px; color: #555;">${new Date(m.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>
</td>
</tr>`).join("")}
</table>
</td>
</tr>`
: "";
const emptyHtml = !hasContent
? `
<tr>
<td style="padding: 32px 0; text-align: center;">
<p style="font-family: Arial, sans-serif; font-size: 14px; color: #555; margin: 0;">No activity this week. Head to the forum to start a discussion!</p>
</td>
</tr>`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Your Weekly BroadcastBeat Digest</title>
</head>
<body style="margin: 0; padding: 0; background-color: #0d0d0d; font-family: Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #0d0d0d; padding: 40px 16px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width: 600px; width: 100%;">
<!-- Header -->
<tr>
<td style="background-color: #111111; border: 1px solid #1e1e1e; border-bottom: none; padding: 32px 40px 24px 40px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<span style="font-family: Georgia, serif; font-size: 20px; font-weight: bold; color: #e8e8e8; letter-spacing: 1px;">BROADCAST<span style="color: #3b82f6;">BEAT</span></span>
</td>
<td style="text-align: right;">
<span style="font-family: Arial, sans-serif; font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 2px;">Weekly Digest</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Hero -->
<tr>
<td style="background-color: #111111; border-left: 1px solid #1e1e1e; border-right: 1px solid #1e1e1e; padding: 0 40px 32px 40px; border-bottom: 2px solid #3b82f6;">
<h1 style="font-family: Georgia, serif; font-size: 26px; font-weight: bold; color: #e8e8e8; margin: 0 0 8px 0; line-height: 1.3;">Your Week in Review</h1>
<p style="font-family: Arial, sans-serif; font-size: 13px; color: #666; margin: 0; text-transform: uppercase; letter-spacing: 1px;">${weekLabel}</p>
<p style="font-family: Arial, sans-serif; font-size: 14px; color: #888; margin: 16px 0 0 0;">Hi <strong style="color: #e0e0e0;">${userName}</strong>, here's a summary of your forum activity this week.</p>
</td>
</tr>
<!-- Stats Row -->
${hasContent ? `
<tr>
<td style="background-color: #0d0d0d; border-left: 1px solid #1e1e1e; border-right: 1px solid #1e1e1e; padding: 24px 40px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="text-align: center; padding: 0 12px; border-right: 1px solid #1e1e1e;">
<p style="font-family: Georgia, serif; font-size: 28px; font-weight: bold; color: #3b82f6; margin: 0;">${replies.length}</p>
<p style="font-family: Arial, sans-serif; font-size: 11px; color: #555; margin: 4px 0 0 0; text-transform: uppercase; letter-spacing: 1px;">Replies</p>
</td>
<td style="text-align: center; padding: 0 12px; border-right: 1px solid #1e1e1e;">
<p style="font-family: Georgia, serif; font-size: 28px; font-weight: bold; color: #22c55e; margin: 0;">${upvotes.reduce((s, u) => s + u.count, 0)}</p>
<p style="font-family: Arial, sans-serif; font-size: 11px; color: #555; margin: 4px 0 0 0; text-transform: uppercase; letter-spacing: 1px;">Upvotes</p>
</td>
<td style="text-align: center; padding: 0 12px;">
<p style="font-family: Georgia, serif; font-size: 28px; font-weight: bold; color: #f59e0b; margin: 0;">${mentions.length}</p>
<p style="font-family: Arial, sans-serif; font-size: 11px; color: #555; margin: 4px 0 0 0; text-transform: uppercase; letter-spacing: 1px;">Mentions</p>
</td>
</tr>
</table>
</td>
</tr>` : ""}
<!-- Content -->
<tr>
<td style="background-color: #111111; border: 1px solid #1e1e1e; border-top: none; padding: 32px 40px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
${repliesHtml}
${upvotesHtml}
${mentionsHtml}
${emptyHtml}
</table>
<!-- CTA -->
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top: 8px;">
<tr>
<td style="text-align: center; padding: 24px 0 0 0; border-top: 1px solid #1e1e1e;">
<a href="https://broadcastbeat.com/forum" style="display: inline-block; background-color: #3b82f6; color: #ffffff; font-family: Arial, sans-serif; font-size: 12px; font-weight: bold; text-transform: uppercase; letter-spacing: 2px; text-decoration: none; padding: 12px 28px; border-radius: 2px;">Visit the Forum</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px 0; text-align: center;">
<p style="font-family: Arial, sans-serif; font-size: 11px; color: #444; margin: 0 0 8px 0;">You're receiving this because you opted in to the weekly digest.</p>
<a href="https://broadcastbeat.com/account" style="font-family: Arial, sans-serif; font-size: 11px; color: #3b82f6; text-decoration: none;">Manage email preferences</a>
<span style="color: #333; margin: 0 8px;">·</span>
<a href="https://broadcastbeat.com" style="font-family: Arial, sans-serif; font-size: 11px; color: #444; text-decoration: none;">BroadcastBeat</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
try {
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
db: { schema: 'bb' },
});
// Parse optional target user_id from body (for single-user send)
let targetUserId: string | null = null;
try {
const body = await req.json();
targetUserId = body?.user_id ?? null;
} catch (_) { /* no body */ }
// Fetch opted-in users
let profileQuery = supabase
.from("user_profiles")
.select("id, email, full_name")
.eq("weekly_digest_enabled", true);
if (targetUserId) {
profileQuery = profileQuery.eq("id", targetUserId);
}
const { data: users, error: usersError } = await profileQuery;
if (usersError) throw usersError;
if (!users || users.length === 0) {
return new Response(JSON.stringify({ sent: 0, message: "No opted-in users" }), {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
});
}
// Week range: last 7 days
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const weekLabel = `${weekAgo.toLocaleDateString("en-US", { month: "long", day: "numeric" })} ${now.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`;
let sent = 0;
const errors: string[] = [];
for (const user of users) {
try {
// 1. Replies: threads where user replied this week
const { data: repliesRaw } = await supabase
.from("forum_replies")
.select("id, body, thread_id, created_at, forum_threads(title)")
.eq("author_id", user.id)
.eq("is_ai_response", false)
.gte("created_at", weekAgo.toISOString())
.order("created_at", { ascending: false })
.limit(5);
const replies = (repliesRaw ?? []).map((r: any) => ({
thread_title: r.forum_threads?.title ?? "Thread",
reply_body: r.body ?? "",
link: `https://broadcastbeat.com/forum/thread/${r.thread_id}`,
created_at: r.created_at,
}));
// 2. Upvotes received this week on user's threads/replies
const { data: threadUpvotes } = await supabase
.from("forum_votes")
.select("target_id, target_type, forum_threads!inner(title, author_id)")
.eq("vote_value", 1)
.eq("target_type", "thread")
.eq("forum_threads.author_id", user.id)
.gte("created_at", weekAgo.toISOString());
const { data: replyUpvotes } = await supabase
.from("forum_votes")
.select("target_id, target_type, forum_replies!inner(author_id, thread_id, forum_threads(title))")
.eq("vote_value", 1)
.eq("target_type", "reply")
.eq("forum_replies.author_id", user.id)
.gte("created_at", weekAgo.toISOString());
// Aggregate upvotes by target
const upvoteMap = new Map<string, { target_type: string; title: string; link: string; count: number }>();
for (const v of (threadUpvotes ?? [])) {
const key = v.target_id;
const title = (v as any).forum_threads?.title ?? "Thread";
if (!upvoteMap.has(key)) {
upvoteMap.set(key, { target_type: "thread", title, link: `https://broadcastbeat.com/forum/thread/${v.target_id}`, count: 0 });
}
upvoteMap.get(key)!.count++;
}
for (const v of (replyUpvotes ?? [])) {
const key = v.target_id;
const threadId = (v as any).forum_replies?.thread_id;
const title = (v as any).forum_replies?.forum_threads?.title ?? "Reply";
if (!upvoteMap.has(key)) {
upvoteMap.set(key, { target_type: "reply", title, link: `https://broadcastbeat.com/forum/thread/${threadId}`, count: 0 });
}
upvoteMap.get(key)!.count++;
}
const upvotes = Array.from(upvoteMap.values()).sort((a, b) => b.count - a.count).slice(0, 5);
// 3. Mentions from notifications this week
const { data: mentionsRaw } = await supabase
.from("notifications")
.select("title, body, link, actor_name, created_at")
.eq("user_id", user.id)
.eq("type", "mention")
.gte("created_at", weekAgo.toISOString())
.order("created_at", { ascending: false })
.limit(5);
const mentions = (mentionsRaw ?? []).map((m: any) => ({
thread_title: m.body ?? m.title ?? "Thread",
actor_name: m.actor_name ?? "Someone",
link: m.link ?? "https://broadcastbeat.com/forum",
created_at: m.created_at,
}));
// Build email HTML
const html = buildDigestHtml({
userName: user.full_name || user.email.split("@")[0],
userEmail: user.email,
replies,
upvotes,
mentions,
weekLabel,
});
// Send via Resend
const resendRes = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "onboarding@resend.dev",
to: [user.email],
subject: `Your BroadcastBeat Weekly Digest — ${weekLabel}`,
html,
}),
});
if (!resendRes.ok) {
const errText = await resendRes.text();
errors.push(`${user.email}: ${errText}`);
} else {
sent++;
}
} catch (userErr: any) {
errors.push(`${user.email}: ${userErr.message}`);
}
}
return new Response(JSON.stringify({ sent, errors: errors.length > 0 ? errors : undefined }), {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
});
} catch (error: any) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
});
}
});