initial commit: rocket.new export of broadcastbeat
This commit is contained in:
164
supabase/functions/send-article-notification/index.ts
Normal file
164
supabase/functions/send-article-notification/index.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { serve } from "https://deno.land/std@0.192.0/http/server.ts";
|
||||
|
||||
serve(async (req) => {
|
||||
// ✅ CORS preflight
|
||||
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 { article, subscribers } = await req.json();
|
||||
|
||||
if (!article || !article.title) {
|
||||
return new Response(JSON.stringify({ error: "Article data is required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!subscribers || subscribers.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "No subscribers provided" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
|
||||
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY");
|
||||
if (!RESEND_API_KEY) {
|
||||
throw new Error("RESEND_API_KEY is not set");
|
||||
}
|
||||
|
||||
const siteUrl = "https://broadcastb5322.builtwithrocket.new";
|
||||
const articleUrl = article.slug
|
||||
? `${siteUrl}/articles/${article.slug}`
|
||||
: article.wp_slug
|
||||
? `${siteUrl}/articles/${article.wp_slug}`
|
||||
: siteUrl;
|
||||
|
||||
const categoryLabel = article.category
|
||||
? `<span style="display:inline-block;background-color:#1e3a5f;color:#3b82f6;font-size:11px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;padding:3px 10px;border-radius:2px;margin-bottom:16px;">${article.category}</span>`
|
||||
: "";
|
||||
|
||||
const featuredImageBlock = article.featured_image
|
||||
? `<img src="${article.featured_image}" alt="${article.featured_image_alt || article.title}" style="width:100%;max-width:600px;height:auto;display:block;margin-bottom:0;" />`
|
||||
: "";
|
||||
|
||||
const excerptBlock = article.excerpt
|
||||
? `<p style="margin:0 0 24px;font-size:15px;color:#aaaaaa;line-height:1.7;">${article.excerpt}</p>`
|
||||
: "";
|
||||
|
||||
const authorBlock = article.author_name
|
||||
? `<p style="margin:0 0 20px;font-size:12px;color:#666;text-transform:uppercase;letter-spacing:0.06em;">By ${article.author_name}</p>`
|
||||
: "";
|
||||
|
||||
const htmlBody = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${article.title} — BroadcastBeat</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#111111;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#111111;padding:40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#1a1a1a;border:1px solid #2a2a2a;border-radius:4px;overflow:hidden;max-width:600px;width:100%;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color:#1a2535;padding:20px 32px;border-bottom:1px solid #2a3a50;">
|
||||
<a href="${siteUrl}" style="text-decoration:none;">
|
||||
<p style="margin:0;font-family:Georgia,serif;font-size:20px;font-weight:bold;color:#e8e8e8;letter-spacing:0.02em;">BroadcastBeat</p>
|
||||
<p style="margin:3px 0 0;font-size:10px;color:#888;letter-spacing:0.06em;text-transform:uppercase;">Digital Platform for Broadcast Engineering</p>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Featured Image -->
|
||||
${featuredImageBlock ? `<tr><td style="padding:0;">${featuredImageBlock}</td></tr>` : ""}
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:28px 32px 32px;">
|
||||
<p style="margin:0 0 12px;font-size:11px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#cc0000;">New Article</p>
|
||||
${categoryLabel}
|
||||
<h1 style="margin:0 0 12px;font-family:Georgia,serif;font-size:22px;color:#e8e8e8;line-height:1.35;">
|
||||
<a href="${articleUrl}" style="color:#e8e8e8;text-decoration:none;">${article.title}</a>
|
||||
</h1>
|
||||
${authorBlock}
|
||||
${excerptBlock}
|
||||
<a href="${articleUrl}" style="display:inline-block;background-color:#cc0000;color:#ffffff;font-size:13px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;text-decoration:none;padding:11px 26px;border-radius:2px;">
|
||||
Read Article →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Divider -->
|
||||
<tr>
|
||||
<td style="padding:0 32px;">
|
||||
<div style="border-top:1px solid #2a2a2a;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color:#0d0d0d;padding:20px 32px;border-top:1px solid #1a1a1a;">
|
||||
<p style="margin:0;font-size:11px;color:#555;line-height:1.6;">
|
||||
You're receiving this because you subscribed to BroadcastBeat article notifications.
|
||||
<br />
|
||||
<a href="${siteUrl}" style="color:#3b82f6;text-decoration:none;">Visit BroadcastBeat</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const results = { sent: 0, failed: 0, errors: [] as string[] };
|
||||
|
||||
// Send in batches of 50 to respect Resend rate limits
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < subscribers.length; i += batchSize) {
|
||||
const batch = subscribers.slice(i, i + batchSize);
|
||||
|
||||
const response = 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: batch,
|
||||
subject: `New Article: ${article.title}`,
|
||||
html: htmlBody,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
results.failed += batch.length;
|
||||
results.errors.push(data.message || "Batch send failed");
|
||||
} else {
|
||||
results.sent += batch.length;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, sent: results.sent, failed: results.failed, errors: results.errors }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
});
|
||||
119
supabase/functions/send-contributor-invite/index.ts
Normal file
119
supabase/functions/send-contributor-invite/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { serve } from "https://deno.land/std@0.192.0/http/server.ts";
|
||||
|
||||
serve(async (req) => {
|
||||
// ✅ CORS preflight
|
||||
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 { email, inviterName, role, token, message, siteUrl } = await req?.json();
|
||||
|
||||
const RESEND_API_KEY = Deno?.env?.get("RESEND_API_KEY");
|
||||
if (!RESEND_API_KEY) {
|
||||
throw new Error("RESEND_API_KEY is not set");
|
||||
}
|
||||
|
||||
const acceptUrl = `${siteUrl || "https://broadcastb5322.builtwithrocket.new"}/invite/accept?token=${token}`;
|
||||
|
||||
const roleLabel = role === "editor" ? "Editor" : role === "admin" ? "Administrator" : "Contributor";
|
||||
|
||||
const htmlBody = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#0d1117;font-family:Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0d1117;padding:40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background:#111827;border-radius:8px;overflow:hidden;border:1px solid #1f2937;">
|
||||
<tr>
|
||||
<td style="background:#1a2535;padding:24px 32px;border-bottom:1px solid #2a3a50;">
|
||||
<p style="margin:0;color:#3b82f6;font-size:13px;font-weight:700;letter-spacing:2px;text-transform:uppercase;">BroadcastBeat</p>
|
||||
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;">Digital Platform for Broadcast Engineering</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:32px;">
|
||||
<h1 style="margin:0 0 16px;color:#f9fafb;font-size:22px;font-weight:700;">You've been invited to contribute</h1>
|
||||
<p style="margin:0 0 16px;color:#9ca3af;font-size:15px;line-height:1.6;">
|
||||
${inviterName || "A BroadcastBeat administrator"} has invited you to join BroadcastBeat as a <strong style="color:#3b82f6;">${roleLabel}</strong>.
|
||||
</p>
|
||||
${message ? `<div style="background:#1f2937;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:4px;margin:0 0 24px;">
|
||||
<p style="margin:0;color:#d1d5db;font-size:14px;font-style:italic;">"${message}"</p>
|
||||
</div>` : ""}
|
||||
<p style="margin:0 0 24px;color:#9ca3af;font-size:14px;line-height:1.6;">
|
||||
As a ${roleLabel}, you'll be able to submit articles for publication on our platform. Click the button below to accept your invitation and set up your account.
|
||||
</p>
|
||||
<table cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="background:#3b82f6;border-radius:6px;">
|
||||
<a href="${acceptUrl}" style="display:inline-block;padding:12px 28px;color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;letter-spacing:0.5px;">Accept Invitation</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:24px 0 0;color:#6b7280;font-size:12px;">
|
||||
This invitation expires in 7 days. If you did not expect this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="margin:12px 0 0;color:#4b5563;font-size:11px;word-break:break-all;">
|
||||
Or copy this link: ${acceptUrl}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#0d1117;padding:16px 32px;border-top:1px solid #1f2937;">
|
||||
<p style="margin:0;color:#4b5563;font-size:11px;text-align:center;">© BroadcastBeat · Digital Platform for Broadcast Engineering</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const res = 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: [email],
|
||||
subject: `You've been invited to contribute to BroadcastBeat`,
|
||||
html: htmlBody,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res?.json();
|
||||
|
||||
if (!res?.ok) {
|
||||
throw new Error(data?.message || "Failed to send email");
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, id: data.id }), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
124
supabase/functions/send-newsletter-confirmation/index.ts
Normal file
124
supabase/functions/send-newsletter-confirmation/index.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { serve } from "https://deno.land/std@0.192.0/http/server.ts";
|
||||
|
||||
serve(async (req) => {
|
||||
// ✅ CORS preflight
|
||||
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 { email, topics } = await req.json();
|
||||
|
||||
if (!email) {
|
||||
return new Response(JSON.stringify({ error: "Email is required" }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY");
|
||||
if (!RESEND_API_KEY) {
|
||||
throw new Error("RESEND_API_KEY is not set");
|
||||
}
|
||||
|
||||
const topicsList = topics && topics.length > 0
|
||||
? `<ul style="margin:12px 0;padding-left:20px;color:#cccccc;">${topics.map((t: string) => `<li style="margin:4px 0;">${t}</li>`).join("")}</ul>`
|
||||
: "<p style='color:#888;'>All topics</p>";
|
||||
|
||||
const htmlBody = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Welcome to BroadcastBeat</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#111111;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#111111;padding:40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#1a1a1a;border:1px solid #2a2a2a;border-radius:4px;overflow:hidden;max-width:600px;width:100%;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color:#1a2535;padding:24px 32px;border-bottom:1px solid #2a3a50;">
|
||||
<p style="margin:0;font-family:Georgia,serif;font-size:22px;font-weight:bold;color:#e8e8e8;letter-spacing:0.02em;">BroadcastBeat</p>
|
||||
<p style="margin:4px 0 0;font-size:11px;color:#888;letter-spacing:0.06em;text-transform:uppercase;">Digital Platform for Broadcast Engineering</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:32px;">
|
||||
<p style="margin:0 0 8px;font-size:13px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#3b82f6;border-left:3px solid #3b82f6;padding-left:8px;">Newsletter Confirmed</p>
|
||||
<h1 style="margin:16px 0 12px;font-family:Georgia,serif;font-size:24px;color:#e8e8e8;line-height:1.3;">You're subscribed!</h1>
|
||||
<p style="margin:0 0 20px;font-size:14px;color:#aaaaaa;line-height:1.6;">
|
||||
Thanks for subscribing to BroadcastBeat. You'll receive the latest broadcast industry news, product launches, and insights delivered straight to your inbox.
|
||||
</p>
|
||||
<p style="margin:0 0 8px;font-size:12px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;color:#888;">Your selected topics:</p>
|
||||
${topicsList}
|
||||
<div style="margin:28px 0;border-top:1px solid #2a2a2a;"></div>
|
||||
<a href="https://broadcastb5322.builtwithrocket.new/home-page" style="display:inline-block;background-color:#cc0000;color:#ffffff;font-size:13px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;text-decoration:none;padding:10px 24px;border-radius:2px;">
|
||||
Read Latest News →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color:#0d0d0d;padding:20px 32px;border-top:1px solid #2a2a2a;">
|
||||
<p style="margin:0;font-size:11px;color:#555;line-height:1.6;">
|
||||
You're receiving this because you subscribed at BroadcastBeat.
|
||||
No spam — unsubscribe anytime by replying to this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const response = 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: [email],
|
||||
subject: "You're subscribed to BroadcastBeat!",
|
||||
html: htmlBody,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Failed to send email");
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, id: data.id }), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
381
supabase/functions/send-weekly-digest/index.ts
Normal file
381
supabase/functions/send-weekly-digest/index.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
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://broadcastb5322.builtwithrocket.new/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://broadcastb5322.builtwithrocket.new/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://broadcastb5322.builtwithrocket.new" 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://broadcastb5322.builtwithrocket.new/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://broadcastb5322.builtwithrocket.new/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://broadcastb5322.builtwithrocket.new/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://broadcastb5322.builtwithrocket.new/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": "*" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user