mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
562 lines
16 KiB
TypeScript
562 lines
16 KiB
TypeScript
|
|
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.",
|
||
|
|
};
|
||
|
|
},
|
||
|
|
});
|