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 unlisted: v.optional(v.boolean()), // Hide from listings but allow direct access via slug docsSection: v.optional(v.boolean()), // Include in docs navigation docsSectionGroup: v.optional(v.string()), // Sidebar group name in docs docsSectionOrder: v.optional(v.number()), // Order within group (lower = first) docsSectionGroupOrder: v.optional(v.number()), // Order of group itself (lower = first) docsSectionGroupIcon: v.optional(v.string()), // Phosphor icon name for sidebar group docsLanding: v.optional(v.boolean()), // Use as /docs landing page lastSyncedAt: v.number(), source: v.optional(v.union(v.literal("dashboard"), v.literal("sync"))), // Content source: "dashboard" (created in UI) or "sync" (from markdown files) embedding: v.optional(v.array(v.float64())), // Vector embedding for semantic search (1536 dimensions, OpenAI text-embedding-ada-002) }) .index("by_slug", ["slug"]) .index("by_date", ["date"]) .index("by_published", ["published"]) .index("by_featured", ["featured"]) .index("by_blogFeatured", ["blogFeatured"]) .index("by_authorName", ["authorName"]) .index("by_docsSection", ["docsSection"]) .index("by_source", ["source"]) .searchIndex("search_content", { searchField: "content", filterFields: ["published"], }) .searchIndex("search_title", { searchField: "title", filterFields: ["published"], }) .vectorIndex("by_embedding", { vectorField: "embedding", dimensions: 1536, 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) textAlign: v.optional(v.string()), // Text alignment: "left", "center", "right" (default: "left") docsSection: v.optional(v.boolean()), // Include in docs navigation docsSectionGroup: v.optional(v.string()), // Sidebar group name in docs docsSectionOrder: v.optional(v.number()), // Order within group (lower = first) docsSectionGroupOrder: v.optional(v.number()), // Order of group itself (lower = first) docsSectionGroupIcon: v.optional(v.string()), // Phosphor icon name for sidebar group docsLanding: v.optional(v.boolean()), // Use as /docs landing page lastSyncedAt: v.number(), source: v.optional(v.union(v.literal("dashboard"), v.literal("sync"))), // Content source: "dashboard" (created in UI) or "sync" (from markdown files) embedding: v.optional(v.array(v.float64())), // Vector embedding for semantic search (1536 dimensions, OpenAI text-embedding-ada-002) }) .index("by_slug", ["slug"]) .index("by_published", ["published"]) .index("by_featured", ["featured"]) .index("by_docsSection", ["docsSection"]) .index("by_source", ["source"]) .searchIndex("search_content", { searchField: "content", filterFields: ["published"], }) .searchIndex("search_title", { searchField: "title", filterFields: ["published"], }) .vectorIndex("by_embedding", { vectorField: "embedding", dimensions: 1536, 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"]), // AI generated images from Gemini image generation aiGeneratedImages: defineTable({ sessionId: v.string(), // Anonymous session ID from localStorage prompt: v.string(), // User's image prompt model: v.string(), // Model used: "gemini-2.5-flash-image" or "gemini-3-pro-image-preview" storageId: v.id("_storage"), // Convex storage ID for the generated image mimeType: v.string(), // Image MIME type: "image/png" or "image/jpeg" createdAt: v.number(), // Timestamp when image was generated }) .index("by_session", ["sessionId"]) .index("by_createdAt", ["createdAt"]), // 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"]), // Ask AI sessions for header AI chat feature // Stores questions and stream IDs for RAG-based Q&A askAISessions: defineTable({ question: v.string(), // User's question streamId: v.string(), // Persistent text streaming ID model: v.optional(v.string()), // Selected AI model createdAt: v.number(), // Timestamp when session was created sources: v.optional( v.array( v.object({ title: v.string(), slug: v.string(), type: v.string(), }) ) ), // Optional sources cited in the response }).index("by_stream", ["streamId"]), // Content version history for posts and pages // Stores snapshots before each update for 3-day retention contentVersions: defineTable({ contentType: v.union(v.literal("post"), v.literal("page")), // Type of content contentId: v.string(), // ID of the post or page (stored as string for flexibility) slug: v.string(), // Slug for display and querying title: v.string(), // Title at time of snapshot content: v.string(), // Full markdown content at time of snapshot description: v.optional(v.string()), // Description (posts only) createdAt: v.number(), // Timestamp when version was created source: v.union( v.literal("sync"), v.literal("dashboard"), v.literal("restore") ), // What triggered the version capture }) .index("by_content", ["contentType", "contentId"]) .index("by_slug", ["contentType", "slug"]) .index("by_createdAt", ["createdAt"]) .index("by_content_createdAt", ["contentType", "contentId", "createdAt"]), // Version control settings // Stores toggle state for version control feature versionControlSettings: defineTable({ key: v.string(), // Setting key: "enabled" value: v.boolean(), // Setting value }).index("by_key", ["key"]), });