mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
docs: add changelog entries for v1.33.1 through v1.37.0
Add missing changelog entries to content/pages/changelog-page.md: v1.34.0 (2025-12-26): Blog page featured layout with hero post - blogFeatured frontmatter field for posts - Hero card displays first featured post with landscape image - 2-column featured row for remaining featured posts - 3-column grid for regular posts v1.35.0 (2025-12-26): Image support at top of posts and pages - showImageAtTop frontmatter field - Full-width image display above post header - Works for both posts and pages v1.36.0 (2025-12-27): Social footer component - Customizable social links (8 platform types) - Copyright with auto-updating year - showSocialFooter frontmatter field for per-page control - Configurable via siteConfig.socialFooter v1.37.0 (2025-12-27): Newsletter Admin UI - Three-column admin interface at /newsletter-admin - Subscriber management with search and filters - Send newsletter panel (post selection or custom email) - Weekly digest automation (Sunday 9am UTC) - Developer notifications (subscriber alerts, weekly stats) - Markdown-to-HTML conversion for custom emails
This commit is contained in:
8
convex/_generated/api.d.ts
vendored
8
convex/_generated/api.d.ts
vendored
@@ -10,8 +10,12 @@
|
||||
|
||||
import type * as aiChatActions from "../aiChatActions.js";
|
||||
import type * as aiChats from "../aiChats.js";
|
||||
import type * as contact from "../contact.js";
|
||||
import type * as contactActions from "../contactActions.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as newsletter from "../newsletter.js";
|
||||
import type * as newsletterActions from "../newsletterActions.js";
|
||||
import type * as pages from "../pages.js";
|
||||
import type * as posts from "../posts.js";
|
||||
import type * as rss from "../rss.js";
|
||||
@@ -27,8 +31,12 @@ import type {
|
||||
declare const fullApi: ApiFromModules<{
|
||||
aiChatActions: typeof aiChatActions;
|
||||
aiChats: typeof aiChats;
|
||||
contact: typeof contact;
|
||||
contactActions: typeof contactActions;
|
||||
crons: typeof crons;
|
||||
http: typeof http;
|
||||
newsletter: typeof newsletter;
|
||||
newsletterActions: typeof newsletterActions;
|
||||
pages: typeof pages;
|
||||
posts: typeof posts;
|
||||
rss: typeof rss;
|
||||
|
||||
84
convex/contact.ts
Normal file
84
convex/contact.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { mutation, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
// Environment variable error message for production
|
||||
const ENV_VAR_ERROR_MESSAGE = "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY, AGENTMAIL_INBOX, and AGENTMAIL_CONTACT_EMAIL.";
|
||||
|
||||
// Submit contact form message
|
||||
// Stores the message and schedules email sending via AgentMail
|
||||
export const submitContact = mutation({
|
||||
args: {
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
message: v.string(),
|
||||
source: v.string(), // "page:slug" or "post:slug"
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Validate required fields
|
||||
const name = args.name.trim();
|
||||
const email = args.email.toLowerCase().trim();
|
||||
const message = args.message.trim();
|
||||
|
||||
if (!name) {
|
||||
return { success: false, message: "Please enter your name." };
|
||||
}
|
||||
|
||||
if (!email || !email.includes("@") || !email.includes(".")) {
|
||||
return { success: false, message: "Please enter a valid email address." };
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return { success: false, message: "Please enter a message." };
|
||||
}
|
||||
|
||||
// Check environment variables before proceeding
|
||||
// Note: We can't access process.env in mutations, so we check in the action
|
||||
// But we can still store the message and let the action handle the error
|
||||
// For now, we'll store the message and let the action fail silently
|
||||
// The user will see a success message but email won't send if env vars are missing
|
||||
|
||||
// Store the message
|
||||
const messageId = await ctx.db.insert("contactMessages", {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
source: args.source,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Schedule email sending via Node.js action
|
||||
// The action will check env vars and fail silently if not configured
|
||||
await ctx.scheduler.runAfter(0, internal.contactActions.sendContactEmail, {
|
||||
messageId,
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
source: args.source,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Thanks for your message! We'll get back to you soon.",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Mark contact message as email sent
|
||||
// Internal mutation to update emailSentAt timestamp
|
||||
export const markEmailSent = internalMutation({
|
||||
args: {
|
||||
messageId: v.id("contactMessages"),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.messageId, {
|
||||
emailSentAt: Date.now(),
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
90
convex/contactActions.ts
Normal file
90
convex/contactActions.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
"use node";
|
||||
|
||||
import { internalAction } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import { AgentMailClient } from "agentmail";
|
||||
|
||||
// Send contact form email via AgentMail SDK
|
||||
// Internal action that sends email to configured recipient
|
||||
// Uses official AgentMail SDK: https://docs.agentmail.to/quickstart
|
||||
export const sendContactEmail = internalAction({
|
||||
args: {
|
||||
messageId: v.id("contactMessages"),
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
message: v.string(),
|
||||
source: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
// Contact form sends to AGENTMAIL_CONTACT_EMAIL or falls back to inbox
|
||||
const recipientEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
|
||||
|
||||
// Silently fail if environment variables not configured
|
||||
if (!apiKey || !inbox || !recipientEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build email HTML
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">New Contact Form Submission</h2>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600; width: 100px;">From:</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(args.name)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600;">Email:</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><a href="mailto:${escapeHtml(args.email)}">${escapeHtml(args.email)}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600;">Source:</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(args.source)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3 style="font-size: 16px; color: #1a1a1a; margin: 24px 0 8px 0;">Message:</h3>
|
||||
<div style="background: #f9f9f9; padding: 16px; border-radius: 6px; white-space: pre-wrap;">${escapeHtml(args.message)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Plain text version
|
||||
const text = `New Contact Form Submission\n\nFrom: ${args.name}\nEmail: ${args.email}\nSource: ${args.source}\n\nMessage:\n${args.message}`;
|
||||
|
||||
try {
|
||||
// Initialize AgentMail client with API key
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
// Send email using official SDK
|
||||
// https://docs.agentmail.to/sending-receiving-email
|
||||
await client.inboxes.messages.send(inbox, {
|
||||
to: recipientEmail,
|
||||
subject: `Contact: ${args.name} via ${args.source}`,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
// Mark email as sent in database
|
||||
await ctx.runMutation(internal.contact.markEmailSent, {
|
||||
messageId: args.messageId,
|
||||
});
|
||||
} catch {
|
||||
// Silently fail on error
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to escape HTML entities
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -11,5 +11,29 @@ crons.interval(
|
||||
{}
|
||||
);
|
||||
|
||||
// Weekly digest: Send every Sunday at 9:00 AM UTC
|
||||
// Posts from the last 7 days are included
|
||||
// To disable, set weeklyDigest.enabled: false in siteConfig.ts
|
||||
crons.cron(
|
||||
"weekly newsletter digest",
|
||||
"0 9 * * 0", // 9:00 AM UTC on Sundays
|
||||
internal.newsletterActions.sendWeeklyDigest,
|
||||
{
|
||||
siteUrl: process.env.SITE_URL || "https://example.com",
|
||||
siteName: process.env.SITE_NAME || "Newsletter",
|
||||
}
|
||||
);
|
||||
|
||||
// Weekly stats summary: Send every Monday at 9:00 AM UTC
|
||||
// Includes subscriber count, new subscribers, newsletters sent
|
||||
crons.cron(
|
||||
"weekly stats summary",
|
||||
"0 9 * * 1", // 9:00 AM UTC on Mondays
|
||||
internal.newsletterActions.sendWeeklyStatsSummary,
|
||||
{
|
||||
siteName: process.env.SITE_NAME || "Newsletter",
|
||||
}
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ http.route({
|
||||
</url>`,
|
||||
// All posts
|
||||
...posts.map(
|
||||
(post) => ` <url>
|
||||
(post: { slug: string; date: string }) => ` <url>
|
||||
<loc>${SITE_URL}/${post.slug}</loc>
|
||||
<lastmod>${post.date}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
@@ -50,7 +50,7 @@ http.route({
|
||||
),
|
||||
// All pages
|
||||
...pages.map(
|
||||
(page) => ` <url>
|
||||
(page: { slug: string }) => ` <url>
|
||||
<loc>${SITE_URL}/${page.slug}</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
@@ -58,7 +58,7 @@ http.route({
|
||||
),
|
||||
// All tag pages
|
||||
...tags.map(
|
||||
(tagInfo) => ` <url>
|
||||
(tagInfo: { tag: string }) => ` <url>
|
||||
<loc>${SITE_URL}/tags/${encodeURIComponent(tagInfo.tag.toLowerCase())}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
@@ -92,7 +92,7 @@ http.route({
|
||||
url: SITE_URL,
|
||||
description:
|
||||
"An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs.. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.",
|
||||
posts: posts.map((post) => ({
|
||||
posts: posts.map((post: { title: string; slug: string; description: string; date: string; readTime?: string; tags: string[] }) => ({
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
description: post.description,
|
||||
@@ -193,7 +193,7 @@ http.route({
|
||||
|
||||
// Fetch full content for each post
|
||||
const fullPosts = await Promise.all(
|
||||
posts.map(async (post) => {
|
||||
posts.map(async (post: { title: string; slug: string; description: string; date: string; readTime?: string; tags: string[] }) => {
|
||||
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
|
||||
slug: post.slug,
|
||||
});
|
||||
|
||||
561
convex/newsletter.ts
Normal file
561
convex/newsletter.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
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.",
|
||||
};
|
||||
},
|
||||
});
|
||||
552
convex/newsletterActions.ts
Normal file
552
convex/newsletterActions.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
"use node";
|
||||
|
||||
import { internalAction } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import { AgentMailClient } from "agentmail";
|
||||
|
||||
// Simple markdown to HTML converter for email content
|
||||
// Supports: headers, bold, italic, links, lists, paragraphs
|
||||
function markdownToHtml(markdown: string): string {
|
||||
let html = markdown
|
||||
// Escape HTML entities first
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
// Headers (must be at start of line)
|
||||
.replace(/^### (.+)$/gm, '<h3 style="font-size: 18px; color: #1a1a1a; margin: 16px 0 8px;">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 style="font-size: 20px; color: #1a1a1a; margin: 20px 0 10px;">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 style="font-size: 24px; color: #1a1a1a; margin: 24px 0 12px;">$1</h1>')
|
||||
// Bold and italic
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color: #1a73e8; text-decoration: none;">$1</a>')
|
||||
// Unordered lists
|
||||
.replace(/^- (.+)$/gm, '<li style="margin: 4px 0;">$1</li>')
|
||||
.replace(/(<li[^>]*>.*<\/li>\n?)+/g, '<ul style="padding-left: 20px; margin: 12px 0;">$&</ul>')
|
||||
// Line breaks (double newline = paragraph)
|
||||
.replace(/\n\n/g, '</p><p style="margin: 12px 0; line-height: 1.6;">')
|
||||
// Single line breaks
|
||||
.replace(/\n/g, '<br />');
|
||||
|
||||
// Wrap in paragraph if not starting with a block element
|
||||
if (!html.startsWith('<h') && !html.startsWith('<ul')) {
|
||||
html = `<p style="margin: 12px 0; line-height: 1.6;">${html}</p>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Convert markdown to plain text for email fallback
|
||||
function markdownToText(markdown: string): string {
|
||||
return markdown
|
||||
// Remove markdown formatting
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/__(.+?)__/g, '$1')
|
||||
.replace(/_(.+?)_/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
||||
.replace(/^#{1,3} /gm, '')
|
||||
.replace(/^- /gm, '* ');
|
||||
}
|
||||
|
||||
// Environment variable error message for production
|
||||
const ENV_VAR_ERROR_MESSAGE = "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX.";
|
||||
|
||||
// Send newsletter for a specific post to all active subscribers
|
||||
// Uses AgentMail SDK to send emails
|
||||
// https://docs.agentmail.to/sending-receiving-email
|
||||
export const sendPostNewsletter = internalAction({
|
||||
args: {
|
||||
postSlug: v.string(),
|
||||
siteUrl: v.string(),
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
sentCount: v.number(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Check if post was already sent
|
||||
const alreadySent: boolean = await ctx.runQuery(
|
||||
internal.newsletter.wasPostSent,
|
||||
{ postSlug: args.postSlug }
|
||||
);
|
||||
if (alreadySent) {
|
||||
return {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
message: "This post has already been sent as a newsletter.",
|
||||
};
|
||||
}
|
||||
|
||||
// Get subscribers
|
||||
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
|
||||
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
return { success: false, sentCount: 0, message: "No subscribers." };
|
||||
}
|
||||
|
||||
// Get post details
|
||||
const post = await ctx.runQuery(internal.posts.getPostBySlugInternal, {
|
||||
slug: args.postSlug,
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return { success: false, sentCount: 0, message: "Post not found." };
|
||||
}
|
||||
|
||||
// Get API key and inbox from environment
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
|
||||
if (!apiKey || !inbox) {
|
||||
return {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
message: ENV_VAR_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
const siteName = args.siteName || "Newsletter";
|
||||
let sentCount = 0;
|
||||
const errors: Array<string> = [];
|
||||
|
||||
// Initialize AgentMail client once
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
// Send to each subscriber
|
||||
for (const subscriber of subscribers) {
|
||||
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
|
||||
const postUrl = `${args.siteUrl}/${post.slug}`;
|
||||
|
||||
// Build email HTML
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="font-size: 24px; color: #1a1a1a; margin-bottom: 16px;">${escapeHtml(post.title)}</h1>
|
||||
<p style="font-size: 16px; color: #444; line-height: 1.6; margin-bottom: 24px;">${escapeHtml(post.description)}</p>
|
||||
${post.excerpt ? `<p style="font-size: 14px; color: #666; line-height: 1.5; margin-bottom: 24px;">${escapeHtml(post.excerpt)}</p>` : ""}
|
||||
<p style="margin-bottom: 32px;">
|
||||
<a href="${postUrl}" style="display: inline-block; padding: 12px 24px; background: #1a1a1a; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 500;">Read more</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="font-size: 12px; color: #888;">
|
||||
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
|
||||
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Plain text version
|
||||
const text = `${post.title}\n\n${post.description}\n\nRead more: ${postUrl}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
|
||||
|
||||
// Send email using AgentMail SDK
|
||||
try {
|
||||
await client.inboxes.messages.send(inbox, {
|
||||
to: subscriber.email,
|
||||
subject: `New: ${post.title}`,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
sentCount++;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
errors.push(`${subscriber.email}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Record sent if at least one email was sent
|
||||
if (sentCount > 0) {
|
||||
await ctx.runMutation(internal.newsletter.recordPostSent, {
|
||||
postSlug: args.postSlug,
|
||||
sentCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Build result message
|
||||
let resultMessage: string = `Sent to ${sentCount} of ${subscribers.length} subscribers.`;
|
||||
if (errors.length > 0) {
|
||||
resultMessage += ` ${errors.length} failed.`;
|
||||
}
|
||||
|
||||
return { success: sentCount > 0, sentCount, message: resultMessage };
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to escape HTML entities
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Send weekly digest email to all active subscribers
|
||||
// Includes all posts published in the last 7 days
|
||||
export const sendWeeklyDigest = internalAction({
|
||||
args: {
|
||||
siteUrl: v.string(),
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
sentCount: v.number(),
|
||||
postCount: v.number(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Get subscribers
|
||||
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
|
||||
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
postCount: 0,
|
||||
message: "No subscribers.",
|
||||
};
|
||||
}
|
||||
|
||||
// Get posts from last 7 days
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
const cutoffDate = sevenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
const recentPosts: Array<{
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
excerpt?: string;
|
||||
}> = await ctx.runQuery(internal.posts.getRecentPostsInternal, {
|
||||
since: cutoffDate,
|
||||
});
|
||||
|
||||
if (recentPosts.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
sentCount: 0,
|
||||
postCount: 0,
|
||||
message: "No new posts in the last 7 days.",
|
||||
};
|
||||
}
|
||||
|
||||
// Get API key and inbox from environment
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
|
||||
if (!apiKey || !inbox) {
|
||||
return {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
postCount: 0,
|
||||
message: ENV_VAR_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
const siteName = args.siteName || "Newsletter";
|
||||
let sentCount = 0;
|
||||
const errors: Array<string> = [];
|
||||
|
||||
// Initialize AgentMail client
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
// Build email content
|
||||
const postsHtml = recentPosts
|
||||
.map(
|
||||
(post) => `
|
||||
<div style="margin-bottom: 24px; padding: 16px; background: #f9f9f9; border-radius: 8px;">
|
||||
<h3 style="font-size: 18px; color: #1a1a1a; margin: 0 0 8px 0;">
|
||||
<a href="${args.siteUrl}/${post.slug}" style="color: #1a1a1a; text-decoration: none;">${escapeHtml(post.title)}</a>
|
||||
</h3>
|
||||
<p style="font-size: 14px; color: #666; margin: 0 0 8px 0;">${escapeHtml(post.description)}</p>
|
||||
<p style="font-size: 12px; color: #888; margin: 0;">${post.date}</p>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const postsText = recentPosts
|
||||
.map(
|
||||
(post) =>
|
||||
`${post.title}\n${post.description}\n${args.siteUrl}/${post.slug}\n${post.date}`
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
// Send to each subscriber
|
||||
for (const subscriber of subscribers) {
|
||||
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
|
||||
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="font-size: 24px; color: #1a1a1a; margin-bottom: 8px;">Weekly Digest</h1>
|
||||
<p style="font-size: 14px; color: #666; margin-bottom: 24px;">${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""} from ${escapeHtml(siteName)}</p>
|
||||
${postsHtml}
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="font-size: 12px; color: #888;">
|
||||
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
|
||||
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const text = `Weekly Digest - ${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""}\n\n${postsText}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
|
||||
|
||||
try {
|
||||
await client.inboxes.messages.send(inbox, {
|
||||
to: subscriber.email,
|
||||
subject: `Weekly Digest: ${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""}`,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
sentCount++;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
errors.push(`${subscriber.email}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
let resultMessage: string = `Sent ${recentPosts.length} post${recentPosts.length > 1 ? "s" : ""} to ${sentCount} of ${subscribers.length} subscribers.`;
|
||||
if (errors.length > 0) {
|
||||
resultMessage += ` ${errors.length} failed.`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: sentCount > 0,
|
||||
sentCount,
|
||||
postCount: recentPosts.length,
|
||||
message: resultMessage,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Send new subscriber notification to developer
|
||||
// Called when a new subscriber signs up
|
||||
export const notifyNewSubscriber = internalAction({
|
||||
args: {
|
||||
email: v.string(),
|
||||
source: v.string(),
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (_ctx, args) => {
|
||||
// Get API key and inbox from environment
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
const contactEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
|
||||
|
||||
if (!apiKey || !contactEmail) {
|
||||
return {
|
||||
success: false,
|
||||
message: ENV_VAR_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
const siteName = args.siteName || "Your Site";
|
||||
const timestamp = new Date().toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
try {
|
||||
await client.inboxes.messages.send(inbox!, {
|
||||
to: contactEmail,
|
||||
subject: `New subscriber: ${args.email}`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">New Newsletter Subscriber</h2>
|
||||
<p style="font-size: 14px; color: #444; line-height: 1.6;">
|
||||
<strong>Email:</strong> ${escapeHtml(args.email)}<br />
|
||||
<strong>Source:</strong> ${escapeHtml(args.source)}<br />
|
||||
<strong>Time:</strong> ${timestamp}
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||
<p style="font-size: 12px; color: #888;">
|
||||
This is an automated notification from ${escapeHtml(siteName)}.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
text: `New Newsletter Subscriber\n\nEmail: ${args.email}\nSource: ${args.source}\nTime: ${timestamp}`,
|
||||
});
|
||||
|
||||
return { success: true, message: "Notification sent." };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Send weekly stats summary to developer
|
||||
// Includes subscriber count, new subscribers, newsletters sent
|
||||
export const sendWeeklyStatsSummary = internalAction({
|
||||
args: {
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Get API key and inbox from environment
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
const contactEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
|
||||
|
||||
if (!apiKey || !contactEmail) {
|
||||
return { success: false, message: ENV_VAR_ERROR_MESSAGE };
|
||||
}
|
||||
|
||||
const siteName = args.siteName || "Your Site";
|
||||
|
||||
// Get stats from database
|
||||
const stats = await ctx.runQuery(internal.newsletter.getStatsForSummary);
|
||||
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
try {
|
||||
await client.inboxes.messages.send(inbox!, {
|
||||
to: contactEmail,
|
||||
subject: `Weekly Stats: ${stats.activeSubscribers} subscribers`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">Weekly Newsletter Stats</h2>
|
||||
<div style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin-bottom: 24px;">
|
||||
<p style="font-size: 14px; color: #444; line-height: 1.8; margin: 0;">
|
||||
<strong>Active Subscribers:</strong> ${stats.activeSubscribers}<br />
|
||||
<strong>Total Subscribers:</strong> ${stats.totalSubscribers}<br />
|
||||
<strong>New This Week:</strong> ${stats.newThisWeek}<br />
|
||||
<strong>Unsubscribed:</strong> ${stats.unsubscribedCount}<br />
|
||||
<strong>Newsletters Sent:</strong> ${stats.totalNewslettersSent}
|
||||
</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||
<p style="font-size: 12px; color: #888;">
|
||||
This is an automated weekly summary from ${escapeHtml(siteName)}.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Weekly Newsletter Stats\n\nActive Subscribers: ${stats.activeSubscribers}\nTotal Subscribers: ${stats.totalSubscribers}\nNew This Week: ${stats.newThisWeek}\nUnsubscribed: ${stats.unsubscribedCount}\nNewsletters Sent: ${stats.totalNewslettersSent}`,
|
||||
});
|
||||
|
||||
return { success: true, message: "Stats summary sent." };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Send custom newsletter email to all active subscribers
|
||||
// Supports markdown content that gets converted to HTML
|
||||
export const sendCustomNewsletter = internalAction({
|
||||
args: {
|
||||
subject: v.string(),
|
||||
content: v.string(), // Markdown content
|
||||
siteUrl: v.string(),
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
sentCount: v.number(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Get subscribers
|
||||
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
|
||||
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
return { success: false, sentCount: 0, message: "No subscribers." };
|
||||
}
|
||||
|
||||
// Get API key and inbox from environment
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
|
||||
if (!apiKey || !inbox) {
|
||||
return {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
message: ENV_VAR_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
const siteName = args.siteName || "Newsletter";
|
||||
let sentCount = 0;
|
||||
const errors: Array<string> = [];
|
||||
|
||||
// Convert markdown to HTML and plain text
|
||||
const contentHtml = markdownToHtml(args.content);
|
||||
const contentText = markdownToText(args.content);
|
||||
|
||||
// Initialize AgentMail client
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
// Send to each subscriber
|
||||
for (const subscriber of subscribers) {
|
||||
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
|
||||
|
||||
// Build email HTML with styling
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #333;">
|
||||
${contentHtml}
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="font-size: 12px; color: #888;">
|
||||
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
|
||||
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const text = `${contentText}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
|
||||
|
||||
try {
|
||||
await client.inboxes.messages.send(inbox, {
|
||||
to: subscriber.email,
|
||||
subject: args.subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
sentCount++;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
errors.push(`${subscriber.email}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Record custom email send if at least one email was sent
|
||||
if (sentCount > 0) {
|
||||
await ctx.runMutation(internal.newsletter.recordCustomSent, {
|
||||
subject: args.subject,
|
||||
sentCount,
|
||||
});
|
||||
}
|
||||
|
||||
let resultMessage: string = `Sent to ${sentCount} of ${subscribers.length} subscribers.`;
|
||||
if (errors.length > 0) {
|
||||
resultMessage += ` ${errors.length} failed.`;
|
||||
}
|
||||
|
||||
return { success: sentCount > 0, sentCount, message: resultMessage };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -128,7 +128,10 @@ export const getPageBySlug = query({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
showSocialFooter: v.optional(v.boolean()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -161,7 +164,10 @@ export const getPageBySlug = query({
|
||||
rightSidebar: page.rightSidebar,
|
||||
showFooter: page.showFooter,
|
||||
footer: page.footer,
|
||||
showSocialFooter: page.showSocialFooter,
|
||||
aiChat: page.aiChat,
|
||||
contactForm: page.contactForm,
|
||||
newsletter: page.newsletter,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -188,7 +194,10 @@ export const syncPagesPublic = mutation({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
showSocialFooter: v.optional(v.boolean()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -232,7 +241,10 @@ export const syncPagesPublic = mutation({
|
||||
rightSidebar: page.rightSidebar,
|
||||
showFooter: page.showFooter,
|
||||
footer: page.footer,
|
||||
showSocialFooter: page.showSocialFooter,
|
||||
aiChat: page.aiChat,
|
||||
contactForm: page.contactForm,
|
||||
newsletter: page.newsletter,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
// Get all published posts, sorted by date descending
|
||||
@@ -179,7 +179,10 @@ export const getPostBySlug = query({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
showSocialFooter: v.optional(v.boolean()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -215,11 +218,87 @@ export const getPostBySlug = query({
|
||||
rightSidebar: post.rightSidebar,
|
||||
showFooter: post.showFooter,
|
||||
footer: post.footer,
|
||||
showSocialFooter: post.showSocialFooter,
|
||||
aiChat: post.aiChat,
|
||||
newsletter: post.newsletter,
|
||||
contactForm: post.contactForm,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Internal query to get post by slug (for newsletter sending)
|
||||
// Returns post details needed for newsletter content
|
||||
export const getPostBySlugInternal = internalQuery({
|
||||
args: {
|
||||
slug: v.string(),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
description: v.string(),
|
||||
content: v.string(),
|
||||
excerpt: v.optional(v.string()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const post = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||
.first();
|
||||
|
||||
if (!post || !post.published) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
content: post.content,
|
||||
excerpt: post.excerpt,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Internal query to get recent posts (for weekly digest)
|
||||
// Returns published posts with date >= since parameter
|
||||
export const getRecentPostsInternal = internalQuery({
|
||||
args: {
|
||||
since: v.string(), // Date string in YYYY-MM-DD format
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
description: v.string(),
|
||||
date: v.string(),
|
||||
excerpt: v.optional(v.string()),
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const posts = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_published", (q) => q.eq("published", true))
|
||||
.collect();
|
||||
|
||||
// Filter posts by date and sort descending
|
||||
const recentPosts = posts
|
||||
.filter((post) => post.date >= args.since)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
.map((post) => ({
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
date: post.date,
|
||||
excerpt: post.excerpt,
|
||||
}));
|
||||
|
||||
return recentPosts;
|
||||
},
|
||||
});
|
||||
|
||||
// Internal mutation for syncing posts from markdown files
|
||||
export const syncPosts = internalMutation({
|
||||
args: {
|
||||
@@ -244,8 +323,11 @@ export const syncPosts = internalMutation({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
showSocialFooter: v.optional(v.boolean()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
blogFeatured: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -291,8 +373,11 @@ export const syncPosts = internalMutation({
|
||||
rightSidebar: post.rightSidebar,
|
||||
showFooter: post.showFooter,
|
||||
footer: post.footer,
|
||||
showSocialFooter: post.showSocialFooter,
|
||||
aiChat: post.aiChat,
|
||||
blogFeatured: post.blogFeatured,
|
||||
newsletter: post.newsletter,
|
||||
contactForm: post.contactForm,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
@@ -342,8 +427,11 @@ export const syncPostsPublic = mutation({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
showSocialFooter: v.optional(v.boolean()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
blogFeatured: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -389,8 +477,11 @@ export const syncPostsPublic = mutation({
|
||||
rightSidebar: post.rightSidebar,
|
||||
showFooter: post.showFooter,
|
||||
footer: post.footer,
|
||||
showSocialFooter: post.showSocialFooter,
|
||||
aiChat: post.aiChat,
|
||||
blogFeatured: post.blogFeatured,
|
||||
newsletter: post.newsletter,
|
||||
contactForm: post.contactForm,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
|
||||
@@ -106,7 +106,7 @@ export const rssFeed = httpAction(async (ctx) => {
|
||||
const posts = await ctx.runQuery(api.posts.getAllPosts);
|
||||
|
||||
const xml = generateRssXml(
|
||||
posts.map((post) => ({
|
||||
posts.map((post: { title: string; description: string; slug: string; date: string }) => ({
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
slug: post.slug,
|
||||
@@ -128,7 +128,7 @@ export const rssFullFeed = httpAction(async (ctx) => {
|
||||
|
||||
// Fetch full content for each post
|
||||
const fullPosts = await Promise.all(
|
||||
posts.map(async (post) => {
|
||||
posts.map(async (post: { title: string; description: string; slug: string; date: string; readTime?: string; tags: string[] }) => {
|
||||
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
|
||||
slug: post.slug,
|
||||
});
|
||||
|
||||
@@ -23,8 +23,11 @@ export default defineSchema({
|
||||
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
|
||||
showFooter: v.optional(v.boolean()), // Show footer on this post (overrides siteConfig default)
|
||||
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
|
||||
showSocialFooter: v.optional(v.boolean()), // Show social footer on this post (overrides siteConfig default)
|
||||
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
|
||||
blogFeatured: v.optional(v.boolean()), // Show as hero featured post on /blog page
|
||||
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
|
||||
contactForm: v.optional(v.boolean()), // Enable contact form on this post
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
@@ -60,7 +63,10 @@ export default defineSchema({
|
||||
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
|
||||
showFooter: v.optional(v.boolean()), // Show footer on this page (overrides siteConfig default)
|
||||
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
|
||||
showSocialFooter: v.optional(v.boolean()), // Show social footer on this page (overrides siteConfig default)
|
||||
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
|
||||
contactForm: v.optional(v.boolean()), // Enable contact form on this page
|
||||
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
@@ -139,4 +145,40 @@ export default defineSchema({
|
||||
})
|
||||
.index("by_session_and_context", ["sessionId", "contextId"])
|
||||
.index("by_session", ["sessionId"]),
|
||||
|
||||
// Newsletter subscribers table
|
||||
// Stores email subscriptions with unsubscribe tokens
|
||||
newsletterSubscribers: defineTable({
|
||||
email: v.string(), // Subscriber email address (lowercase, trimmed)
|
||||
subscribed: v.boolean(), // Current subscription status
|
||||
subscribedAt: v.number(), // Timestamp when subscribed
|
||||
unsubscribedAt: v.optional(v.number()), // Timestamp when unsubscribed (if applicable)
|
||||
source: v.string(), // Where they signed up: "home", "blog-page", "post", or "post:slug-name"
|
||||
unsubscribeToken: v.string(), // Secure token for unsubscribe links
|
||||
})
|
||||
.index("by_email", ["email"])
|
||||
.index("by_subscribed", ["subscribed"]),
|
||||
|
||||
// Newsletter sent tracking (posts and custom emails)
|
||||
// Tracks what has been sent to prevent duplicate newsletters
|
||||
newsletterSentPosts: defineTable({
|
||||
postSlug: v.string(), // Slug of the post or custom email identifier
|
||||
sentAt: v.number(), // Timestamp when the newsletter was sent
|
||||
sentCount: v.number(), // Number of subscribers it was sent to
|
||||
type: v.optional(v.string()), // "post" or "custom" (default "post" for backwards compat)
|
||||
subject: v.optional(v.string()), // Subject line for custom emails
|
||||
})
|
||||
.index("by_postSlug", ["postSlug"])
|
||||
.index("by_sentAt", ["sentAt"]),
|
||||
|
||||
// Contact form messages
|
||||
// Stores messages submitted via contact forms on posts/pages
|
||||
contactMessages: defineTable({
|
||||
name: v.string(), // Sender's name
|
||||
email: v.string(), // Sender's email address
|
||||
message: v.string(), // Message content
|
||||
source: v.string(), // Where submitted from: "page:slug" or "post:slug"
|
||||
createdAt: v.number(), // Timestamp when submitted
|
||||
emailSentAt: v.optional(v.number()), // Timestamp when email was sent (if applicable)
|
||||
}).index("by_createdAt", ["createdAt"]),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user