import { query, mutation, internalMutation } from "./_generated/server"; import { v } from "convex/values"; // Get all published posts, sorted by date descending export const getAllPosts = query({ args: {}, returns: v.array( v.object({ _id: v.id("posts"), _creationTime: v.number(), slug: v.string(), title: v.string(), description: v.string(), date: v.string(), published: v.boolean(), tags: v.array(v.string()), readTime: v.optional(v.string()), image: v.optional(v.string()), excerpt: v.optional(v.string()), featured: v.optional(v.boolean()), featuredOrder: v.optional(v.number()), }), ), handler: async (ctx) => { const posts = await ctx.db .query("posts") .withIndex("by_published", (q) => q.eq("published", true)) .collect(); // Sort by date descending const sortedPosts = posts.sort( (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), ); // Return without content for list view return sortedPosts.map((post) => ({ _id: post._id, _creationTime: post._creationTime, slug: post.slug, title: post.title, description: post.description, date: post.date, published: post.published, tags: post.tags, readTime: post.readTime, image: post.image, excerpt: post.excerpt, featured: post.featured, featuredOrder: post.featuredOrder, })); }, }); // Get featured posts for the homepage featured section export const getFeaturedPosts = query({ args: {}, returns: v.array( v.object({ _id: v.id("posts"), slug: v.string(), title: v.string(), excerpt: v.optional(v.string()), description: v.string(), image: v.optional(v.string()), featuredOrder: v.optional(v.number()), }), ), handler: async (ctx) => { const posts = await ctx.db .query("posts") .withIndex("by_featured", (q) => q.eq("featured", true)) .collect(); // Filter to only published posts and sort by featuredOrder const featuredPosts = posts .filter((p) => p.published) .sort((a, b) => { const orderA = a.featuredOrder ?? 999; const orderB = b.featuredOrder ?? 999; return orderA - orderB; }); return featuredPosts.map((post) => ({ _id: post._id, slug: post.slug, title: post.title, excerpt: post.excerpt, description: post.description, image: post.image, featuredOrder: post.featuredOrder, })); }, }); // Get a single post by slug export const getPostBySlug = query({ args: { slug: v.string(), }, returns: v.union( v.object({ _id: v.id("posts"), _creationTime: v.number(), 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()), excerpt: v.optional(v.string()), featured: v.optional(v.boolean()), featuredOrder: v.optional(v.number()), }), 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 { _id: post._id, _creationTime: post._creationTime, slug: post.slug, title: post.title, description: post.description, content: post.content, date: post.date, published: post.published, tags: post.tags, readTime: post.readTime, image: post.image, excerpt: post.excerpt, featured: post.featured, featuredOrder: post.featuredOrder, }; }, }); // Internal mutation for syncing posts from markdown files export const syncPosts = internalMutation({ args: { posts: v.array( v.object({ 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()), excerpt: v.optional(v.string()), featured: v.optional(v.boolean()), featuredOrder: v.optional(v.number()), }), ), }, returns: v.object({ created: v.number(), updated: v.number(), deleted: v.number(), }), handler: async (ctx, args) => { let created = 0; let updated = 0; let deleted = 0; const now = Date.now(); const incomingSlugs = new Set(args.posts.map((p) => p.slug)); // Get all existing posts const existingPosts = await ctx.db.query("posts").collect(); const existingBySlug = new Map(existingPosts.map((p) => [p.slug, p])); // Upsert incoming posts for (const post of args.posts) { const existing = existingBySlug.get(post.slug); if (existing) { // Update existing post await ctx.db.patch(existing._id, { title: post.title, description: post.description, content: post.content, date: post.date, published: post.published, tags: post.tags, readTime: post.readTime, image: post.image, excerpt: post.excerpt, featured: post.featured, featuredOrder: post.featuredOrder, lastSyncedAt: now, }); updated++; } else { // Create new post await ctx.db.insert("posts", { ...post, lastSyncedAt: now, }); created++; } } // Delete posts that no longer exist in the repo for (const existing of existingPosts) { if (!incomingSlugs.has(existing.slug)) { await ctx.db.delete(existing._id); deleted++; } } return { created, updated, deleted }; }, }); // Public mutation wrapper for sync script (no auth required for build-time sync) export const syncPostsPublic = mutation({ args: { posts: v.array( v.object({ 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()), excerpt: v.optional(v.string()), featured: v.optional(v.boolean()), featuredOrder: v.optional(v.number()), }), ), }, returns: v.object({ created: v.number(), updated: v.number(), deleted: v.number(), }), handler: async (ctx, args) => { let created = 0; let updated = 0; let deleted = 0; const now = Date.now(); const incomingSlugs = new Set(args.posts.map((p) => p.slug)); // Get all existing posts const existingPosts = await ctx.db.query("posts").collect(); const existingBySlug = new Map(existingPosts.map((p) => [p.slug, p])); // Upsert incoming posts for (const post of args.posts) { const existing = existingBySlug.get(post.slug); if (existing) { // Update existing post await ctx.db.patch(existing._id, { title: post.title, description: post.description, content: post.content, date: post.date, published: post.published, tags: post.tags, readTime: post.readTime, image: post.image, excerpt: post.excerpt, featured: post.featured, featuredOrder: post.featuredOrder, lastSyncedAt: now, }); updated++; } else { // Create new post await ctx.db.insert("posts", { ...post, lastSyncedAt: now, }); created++; } } // Delete posts that no longer exist in the repo for (const existing of existingPosts) { if (!incomingSlugs.has(existing.slug)) { await ctx.db.delete(existing._id); deleted++; } } return { created, updated, deleted }; }, }); // Public mutation for incrementing view count export const incrementViewCount = mutation({ args: { slug: v.string(), }, returns: v.null(), handler: async (ctx, args) => { const existing = await ctx.db .query("viewCounts") .withIndex("by_slug", (q) => q.eq("slug", args.slug)) .first(); if (existing) { await ctx.db.patch(existing._id, { count: existing.count + 1, }); } else { await ctx.db.insert("viewCounts", { slug: args.slug, count: 1, }); } return null; }, }); // Get view count for a post export const getViewCount = query({ args: { slug: v.string(), }, returns: v.number(), handler: async (ctx, args) => { const viewCount = await ctx.db .query("viewCounts") .withIndex("by_slug", (q) => q.eq("slug", args.slug)) .first(); return viewCount?.count ?? 0; }, });