Files
wiki/convex/newsletter.ts

562 lines
16 KiB
TypeScript
Raw Normal View History

import {
mutation,
query,
internalQuery,
internalMutation,
} from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Generate secure unsubscribe token
// Uses random alphanumeric characters for URL-safe tokens
function generateToken(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let token = "";
for (let i = 0; i < 32; i++) {
token += chars[Math.floor(Math.random() * chars.length)];
}
return token;
}
// Subscribe to newsletter (email only)
// Creates new subscriber or re-subscribes existing unsubscribed user
// Sends developer notification when a new subscriber signs up
export const subscribe = mutation({
args: {
email: v.string(),
source: v.string(), // "home", "blog-page", "post", or "post:slug-name"
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Normalize email: lowercase and trim whitespace
const email = args.email.toLowerCase().trim();
// Validate email format
if (!email || !email.includes("@") || !email.includes(".")) {
return { success: false, message: "Please enter a valid email address." };
}
// Check if already subscribed using index
const existing = await ctx.db
.query("newsletterSubscribers")
.withIndex("by_email", (q) => q.eq("email", email))
.first();
if (existing && existing.subscribed) {
return { success: false, message: "You're already subscribed!" };
}
const token = generateToken();
const isNewSubscriber = !existing;
if (existing) {
// Re-subscribe existing user with new token
await ctx.db.patch(existing._id, {
subscribed: true,
subscribedAt: Date.now(),
source: args.source,
unsubscribeToken: token,
unsubscribedAt: undefined,
});
} else {
// Create new subscriber
await ctx.db.insert("newsletterSubscribers", {
email,
subscribed: true,
subscribedAt: Date.now(),
source: args.source,
unsubscribeToken: token,
});
}
// Send developer notification for new subscribers
// Only for genuinely new subscribers, not re-subscriptions
if (isNewSubscriber) {
await ctx.scheduler.runAfter(0, internal.newsletterActions.notifyNewSubscriber, {
email,
source: args.source,
});
}
return { success: true, message: "Thanks for subscribing!" };
},
});
// Unsubscribe from newsletter
// Requires email and token for security (prevents unauthorized unsubscribes)
export const unsubscribe = mutation({
args: {
email: v.string(),
token: v.string(),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Normalize email
const email = args.email.toLowerCase().trim();
// Find subscriber by email using index
const subscriber = await ctx.db
.query("newsletterSubscribers")
.withIndex("by_email", (q) => q.eq("email", email))
.first();
if (!subscriber) {
return { success: false, message: "Email not found." };
}
// Verify token matches
if (subscriber.unsubscribeToken !== args.token) {
return { success: false, message: "Invalid unsubscribe link." };
}
// Check if already unsubscribed
if (!subscriber.subscribed) {
return { success: true, message: "You're already unsubscribed." };
}
// Mark as unsubscribed
await ctx.db.patch(subscriber._id, {
subscribed: false,
unsubscribedAt: Date.now(),
});
return { success: true, message: "You've been unsubscribed." };
},
});
// Get subscriber count (for stats page)
// Returns count of active subscribers
export const getSubscriberCount = query({
args: {},
returns: v.number(),
handler: async (ctx) => {
const subscribers = await ctx.db
.query("newsletterSubscribers")
.withIndex("by_subscribed", (q) => q.eq("subscribed", true))
.collect();
return subscribers.length;
},
});
// Get active subscribers (internal, for sending newsletters)
// Returns only email and token for each active subscriber
export const getActiveSubscribers = internalQuery({
args: {},
returns: v.array(
v.object({
email: v.string(),
unsubscribeToken: v.string(),
})
),
handler: async (ctx) => {
const subscribers = await ctx.db
.query("newsletterSubscribers")
.withIndex("by_subscribed", (q) => q.eq("subscribed", true))
.collect();
return subscribers.map((s) => ({
email: s.email,
unsubscribeToken: s.unsubscribeToken,
}));
},
});
// Record that a post was sent as newsletter
// Internal mutation called after sending newsletter
export const recordPostSent = internalMutation({
args: {
postSlug: v.string(),
sentCount: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.insert("newsletterSentPosts", {
postSlug: args.postSlug,
sentAt: Date.now(),
sentCount: args.sentCount,
type: "post",
});
return null;
},
});
// Record that a custom email was sent as newsletter
// Internal mutation called after sending custom newsletter
export const recordCustomSent = internalMutation({
args: {
subject: v.string(),
sentCount: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Generate a unique identifier for the custom email
const customId = `custom-${Date.now()}`;
await ctx.db.insert("newsletterSentPosts", {
postSlug: customId,
sentAt: Date.now(),
sentCount: args.sentCount,
type: "custom",
subject: args.subject,
});
return null;
},
});
// Check if a post has already been sent as newsletter
export const wasPostSent = internalQuery({
args: {
postSlug: v.string(),
},
returns: v.boolean(),
handler: async (ctx, args) => {
const sent = await ctx.db
.query("newsletterSentPosts")
.withIndex("by_postSlug", (q) => q.eq("postSlug", args.postSlug))
.first();
return sent !== null;
},
});
// ============================================================================
// Admin Queries and Mutations
// For use in the /newsletter-admin page
// ============================================================================
// Subscriber type for admin queries (excludes sensitive tokens for public queries)
const subscriberAdminValidator = v.object({
_id: v.id("newsletterSubscribers"),
email: v.string(),
subscribed: v.boolean(),
subscribedAt: v.number(),
unsubscribedAt: v.optional(v.number()),
source: v.string(),
});
// Get all subscribers for admin (paginated)
// Returns subscribers without sensitive unsubscribe tokens
export const getAllSubscribers = query({
args: {
limit: v.optional(v.number()),
cursor: v.optional(v.string()),
filter: v.optional(v.union(v.literal("all"), v.literal("subscribed"), v.literal("unsubscribed"))),
search: v.optional(v.string()),
},
returns: v.object({
subscribers: v.array(subscriberAdminValidator),
nextCursor: v.union(v.string(), v.null()),
totalCount: v.number(),
subscribedCount: v.number(),
}),
handler: async (ctx, args) => {
const limit = args.limit ?? 50;
const filter = args.filter ?? "all";
const search = args.search?.toLowerCase().trim();
// Get all subscribers for counting
const allSubscribers = await ctx.db
.query("newsletterSubscribers")
.collect();
// Filter by subscription status
let filtered = allSubscribers;
if (filter === "subscribed") {
filtered = allSubscribers.filter((s) => s.subscribed);
} else if (filter === "unsubscribed") {
filtered = allSubscribers.filter((s) => !s.subscribed);
}
// Search by email
if (search) {
filtered = filtered.filter((s) => s.email.includes(search));
}
// Sort by subscribedAt descending (newest first)
filtered.sort((a, b) => b.subscribedAt - a.subscribedAt);
// Pagination using cursor (subscribedAt timestamp)
let startIndex = 0;
if (args.cursor) {
const cursorTime = parseInt(args.cursor, 10);
startIndex = filtered.findIndex((s) => s.subscribedAt < cursorTime);
if (startIndex === -1) startIndex = filtered.length;
}
const pageSubscribers = filtered.slice(startIndex, startIndex + limit);
const hasMore = startIndex + limit < filtered.length;
// Map to admin format (strip unsubscribeToken)
const subscribers = pageSubscribers.map((s) => ({
_id: s._id,
email: s.email,
subscribed: s.subscribed,
subscribedAt: s.subscribedAt,
unsubscribedAt: s.unsubscribedAt,
source: s.source,
}));
const subscribedCount = allSubscribers.filter((s) => s.subscribed).length;
return {
subscribers,
nextCursor: hasMore ? String(pageSubscribers[pageSubscribers.length - 1].subscribedAt) : null,
totalCount: filtered.length,
subscribedCount,
};
},
});
// Delete subscriber (admin only)
// Permanently removes subscriber from database
export const deleteSubscriber = mutation({
args: {
subscriberId: v.id("newsletterSubscribers"),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Check if subscriber exists using direct get
const subscriber = await ctx.db.get(args.subscriberId);
if (!subscriber) {
return { success: false, message: "Subscriber not found." };
}
// Delete the subscriber
await ctx.db.delete(args.subscriberId);
return { success: true, message: "Subscriber deleted." };
},
});
// Get newsletter stats for admin dashboard
export const getNewsletterStats = query({
args: {},
returns: v.object({
totalSubscribers: v.number(),
activeSubscribers: v.number(),
unsubscribedCount: v.number(),
totalNewslettersSent: v.number(),
totalEmailsSent: v.number(), // Sum of all sentCount
recentNewsletters: v.array(
v.object({
postSlug: v.string(),
sentAt: v.number(),
sentCount: v.number(),
type: v.optional(v.string()),
subject: v.optional(v.string()),
})
),
}),
handler: async (ctx) => {
// Get all subscribers
const subscribers = await ctx.db.query("newsletterSubscribers").collect();
const activeSubscribers = subscribers.filter((s) => s.subscribed).length;
const unsubscribedCount = subscribers.length - activeSubscribers;
// Get sent newsletters
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
// Calculate total emails sent (sum of all sentCount)
const totalEmailsSent = sentPosts.reduce((sum, p) => sum + p.sentCount, 0);
// Sort by sentAt descending and take last 10
const recentNewsletters = sentPosts
.sort((a, b) => b.sentAt - a.sentAt)
.slice(0, 10)
.map((p) => ({
postSlug: p.postSlug,
sentAt: p.sentAt,
sentCount: p.sentCount,
type: p.type,
subject: p.subject,
}));
return {
totalSubscribers: subscribers.length,
activeSubscribers,
unsubscribedCount,
totalNewslettersSent: sentPosts.length,
totalEmailsSent,
recentNewsletters,
};
},
});
// Get list of posts available for newsletter sending
export const getPostsForNewsletter = query({
args: {},
returns: v.array(
v.object({
slug: v.string(),
title: v.string(),
date: v.string(),
wasSent: v.boolean(),
})
),
handler: async (ctx) => {
// Get all published posts
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Get all sent post slugs
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
const sentSlugs = new Set(sentPosts.map((p) => p.postSlug));
// Map posts with sent status, sorted by date descending
return posts
.sort((a, b) => b.date.localeCompare(a.date))
.map((p) => ({
slug: p.slug,
title: p.title,
date: p.date,
wasSent: sentSlugs.has(p.slug),
}));
},
});
// Internal query to get stats for weekly summary email
export const getStatsForSummary = internalQuery({
args: {},
returns: v.object({
activeSubscribers: v.number(),
totalSubscribers: v.number(),
newThisWeek: v.number(),
unsubscribedCount: v.number(),
totalNewslettersSent: v.number(),
}),
handler: async (ctx) => {
// Get all subscribers
const subscribers = await ctx.db.query("newsletterSubscribers").collect();
const activeSubscribers = subscribers.filter((s) => s.subscribed).length;
const unsubscribedCount = subscribers.length - activeSubscribers;
// Calculate new subscribers this week
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const newThisWeek = subscribers.filter(
(s) => s.subscribedAt >= oneWeekAgo && s.subscribed
).length;
// Get sent newsletters count
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
return {
activeSubscribers,
totalSubscribers: subscribers.length,
newThisWeek,
unsubscribedCount,
totalNewslettersSent: sentPosts.length,
};
},
});
// ============================================================================
// Admin Mutations for Newsletter Sending
// These schedule internal actions to send newsletters
// ============================================================================
// Schedule sending a post as newsletter from admin UI
export const scheduleSendPostNewsletter = mutation({
args: {
postSlug: v.string(),
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Check if post was already sent
const sent = await ctx.db
.query("newsletterSentPosts")
.withIndex("by_postSlug", (q) => q.eq("postSlug", args.postSlug))
.first();
if (sent) {
return {
success: false,
message: "This post has already been sent as a newsletter.",
};
}
// Schedule the action to run immediately
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendPostNewsletter, {
postSlug: args.postSlug,
siteUrl: args.siteUrl,
siteName: args.siteName,
});
return {
success: true,
message: "Newsletter is being sent. Check back in a moment for results.",
};
},
});
// Schedule sending a custom newsletter from admin UI
export const scheduleSendCustomNewsletter = mutation({
args: {
subject: v.string(),
content: v.string(),
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Validate inputs
if (!args.subject.trim()) {
return { success: false, message: "Subject is required." };
}
if (!args.content.trim()) {
return { success: false, message: "Content is required." };
}
// Schedule the action to run immediately
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendCustomNewsletter, {
subject: args.subject,
content: args.content,
siteUrl: args.siteUrl,
siteName: args.siteName,
});
return {
success: true,
message: "Newsletter is being sent. Check back in a moment for results.",
};
},
});
// Schedule sending weekly stats summary from CLI
export const scheduleSendStatsSummary = mutation({
args: {
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Schedule the action to run immediately
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendWeeklyStatsSummary, {
siteName: args.siteName,
});
return {
success: true,
message: "Stats summary is being sent. Check your inbox in a moment.",
};
},
});