Files
wiki/convex/schema.ts
Wayne Sutton a87db9d171 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
2025-12-27 15:32:07 -08:00

185 lines
7.8 KiB
TypeScript

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
// Blog posts table
posts: defineTable({
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()), // Header/OG image URL
showImageAtTop: v.optional(v.boolean()), // Display image at top of post (default: false)
excerpt: v.optional(v.string()), // Short excerpt for card view
featured: v.optional(v.boolean()), // Show in featured section
featuredOrder: v.optional(v.number()), // Order in featured section (lower = first)
authorName: v.optional(v.string()), // Author display name
authorImage: v.optional(v.string()), // Author avatar image URL (round)
layout: v.optional(v.string()), // Layout type: "sidebar" for docs-style layout
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"])
.index("by_date", ["date"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
.index("by_blogFeatured", ["blogFeatured"])
.searchIndex("search_content", {
searchField: "content",
filterFields: ["published"],
})
.searchIndex("search_title", {
searchField: "title",
filterFields: ["published"],
}),
// Static pages (about, projects, contact, etc.)
pages: defineTable({
slug: v.string(),
title: v.string(),
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()), // Display order in nav
showInNav: v.optional(v.boolean()), // Show in navigation menu (default: true)
excerpt: v.optional(v.string()), // Short excerpt for card view
image: v.optional(v.string()), // Thumbnail/OG image URL for featured cards
showImageAtTop: v.optional(v.boolean()), // Display image at top of page (default: false)
featured: v.optional(v.boolean()), // Show in featured section
featuredOrder: v.optional(v.number()), // Order in featured section (lower = first)
authorName: v.optional(v.string()), // Author display name
authorImage: v.optional(v.string()), // Author avatar image URL (round)
layout: v.optional(v.string()), // Layout type: "sidebar" for docs-style layout
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"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
.searchIndex("search_content", {
searchField: "content",
filterFields: ["published"],
})
.searchIndex("search_title", {
searchField: "title",
filterFields: ["published"],
}),
// View counts for analytics
viewCounts: defineTable({
slug: v.string(),
count: v.number(),
}).index("by_slug", ["slug"]),
// Site configuration (about content, links, etc.)
siteConfig: defineTable({
key: v.string(),
value: v.any(),
}).index("by_key", ["key"]),
// Page view events for analytics (event records pattern)
pageViews: defineTable({
path: v.string(),
pageType: v.string(), // "blog" | "page" | "home" | "stats"
sessionId: v.string(),
timestamp: v.number(),
})
.index("by_path", ["path"])
.index("by_timestamp", ["timestamp"])
.index("by_session_path", ["sessionId", "path"]),
// Active sessions for real-time visitor tracking
activeSessions: defineTable({
sessionId: v.string(),
currentPath: v.string(),
lastSeen: v.number(),
// Location data (optional, from Netlify geo headers)
city: v.optional(v.string()),
country: v.optional(v.string()),
latitude: v.optional(v.number()),
longitude: v.optional(v.number()),
})
.index("by_sessionId", ["sessionId"])
.index("by_lastSeen", ["lastSeen"]),
// AI chat conversations for writing assistant
aiChats: defineTable({
sessionId: v.string(), // Anonymous session ID from localStorage
contextId: v.string(), // Slug or "write-page" identifier
messages: v.array(
v.object({
role: v.union(v.literal("user"), v.literal("assistant")),
content: v.string(),
timestamp: v.number(),
attachments: v.optional(
v.array(
v.object({
type: v.union(v.literal("image"), v.literal("link")),
storageId: v.optional(v.id("_storage")),
url: v.optional(v.string()),
scrapedContent: v.optional(v.string()),
title: v.optional(v.string()),
}),
),
),
}),
),
pageContext: v.optional(v.string()), // Loaded page markdown content
lastMessageAt: v.optional(v.number()),
})
.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"]),
});