import { query, mutation, action, internalMutation } from "./_generated/server"; import { v } from "convex/values"; import * as Users from "./model/users"; import { recalculateProductRating } from "./model/products"; import type { Id } from "./_generated/dataModel"; import type { QueryCtx } from "./_generated/server"; import { internal, api } from "./_generated/api"; type CtxWithDb = Pick; /** * Returns true if the user has at least one delivered order that contains * an order item whose variant belongs to the given product. */ async function hasVerifiedPurchase( ctx: CtxWithDb, userId: Id<"users">, productId: Id<"products">, ): Promise { const orders = await ctx.db .query("orders") .withIndex("by_user", (q) => q.eq("userId", userId)) .collect(); const deliveredOrders = orders.filter((o) => o.status === "delivered"); for (const order of deliveredOrders) { const items = await ctx.db .query("orderItems") .withIndex("by_order", (q) => q.eq("orderId", order._id)) .collect(); for (const item of items) { const variant = await ctx.db.get(item.variantId); if (variant?.productId === productId) return true; } } return false; } export const listByProduct = query({ args: { productId: v.id("products"), limit: v.optional(v.number()), offset: v.optional(v.number()), }, handler: async (ctx, args) => { const limit = args.limit ?? 20; const offset = args.offset ?? 0; const all = await ctx.db .query("reviews") .withIndex("by_product_approved", (q) => q.eq("productId", args.productId).eq("isApproved", true), ) .collect(); all.sort((a, b) => b.createdAt - a.createdAt); const total = all.length; const page = all.slice(offset, offset + limit); const users = await Promise.all( page.map((r) => ctx.db.get(r.userId)), ); const withUser = page.map((r, i) => ({ ...r, userName: users[i]?.name, })); return { page: withUser, total, hasMore: offset + limit < total }; }, }); export const create = mutation({ args: { productId: v.id("products"), rating: v.number(), title: v.string(), content: v.string(), images: v.optional(v.array(v.string())), }, handler: async (ctx, args) => { const user = await Users.getCurrentUserOrThrow(ctx); if (args.rating < 1 || args.rating > 5) { throw new Error("Rating must be between 1 and 5"); } const existing = await ctx.db .query("reviews") .withIndex("by_user", (q) => q.eq("userId", user._id)) .collect(); if (existing.some((r) => r.productId === args.productId)) { throw new Error("You have already reviewed this product"); } const verifiedPurchase = await hasVerifiedPurchase(ctx, user._id, args.productId); const now = Date.now(); return await ctx.db.insert("reviews", { productId: args.productId, userId: user._id, rating: args.rating, title: args.title, content: args.content, images: args.images, verifiedPurchase, helpfulCount: 0, createdAt: now, updatedAt: now, isApproved: false, }); }, }); export const listForAdmin = query({ args: { limit: v.optional(v.number()), offset: v.optional(v.number()), productId: v.optional(v.id("products")), isApproved: v.optional(v.boolean()), }, handler: async (ctx, args) => { await Users.requireAdmin(ctx); const limit = args.limit ?? 20; const offset = args.offset ?? 0; let list; if (args.productId !== undefined && args.isApproved !== undefined) { list = await ctx.db .query("reviews") .withIndex("by_product_approved", (idx) => idx.eq("productId", args.productId!).eq("isApproved", args.isApproved!), ) .collect(); } else if (args.productId !== undefined) { list = await ctx.db .query("reviews") .withIndex("by_product", (idx) => idx.eq("productId", args.productId!), ) .collect(); } else if (args.isApproved !== undefined) { list = await ctx.db .query("reviews") .filter((q) => q.eq(q.field("isApproved"), args.isApproved)) .collect(); } else { list = await ctx.db.query("reviews").collect(); } list.sort((a, b) => b.createdAt - a.createdAt); const total = list.length; const page = list.slice(offset, offset + limit); return { page, total, hasMore: offset + limit < total }; }, }); export const approve = mutation({ args: { id: v.id("reviews") }, handler: async (ctx, { id }) => { await Users.requireAdmin(ctx); const review = await ctx.db.get(id); if (!review) throw new Error("Review not found"); await ctx.db.patch(id, { isApproved: true, updatedAt: Date.now(), }); await recalculateProductRating(ctx, review.productId); }, }); export const deleteReview = mutation({ args: { id: v.id("reviews") }, handler: async (ctx, { id }) => { await Users.requireAdmin(ctx); const review = await ctx.db.get(id); if (!review) throw new Error("Review not found"); const productId = review.productId; await ctx.db.delete(id); await recalculateProductRating(ctx, productId); }, }); export const listByProductSorted = query({ args: { productId: v.id("products"), limit: v.optional(v.number()), offset: v.optional(v.number()), sortBy: v.optional( v.union( v.literal("newest"), v.literal("oldest"), v.literal("highest"), v.literal("lowest"), v.literal("helpful"), ), ), }, handler: async (ctx, args) => { const limit = args.limit ?? 10; const offset = args.offset ?? 0; const sortBy = args.sortBy ?? "newest"; const all = await ctx.db .query("reviews") .withIndex("by_product_approved", (q) => q.eq("productId", args.productId).eq("isApproved", true), ) .collect(); // Sort in-memory all.sort((a, b) => { switch (sortBy) { case "oldest": return a.createdAt - b.createdAt; case "highest": return b.rating - a.rating || b.createdAt - a.createdAt; case "lowest": return a.rating - b.rating || b.createdAt - a.createdAt; case "helpful": return b.helpfulCount - a.helpfulCount || b.createdAt - a.createdAt; default: // "newest" return b.createdAt - a.createdAt; } }); const total = all.length; const page = all.slice(offset, offset + limit); const users = await Promise.all(page.map((r) => ctx.db.get(r.userId))); const withUser = page.map((r, i) => ({ ...r, userName: users[i]?.name, })); return { page: withUser, total, hasMore: offset + limit < total }; }, }); export const hasUserReviewed = query({ args: { productId: v.id("products") }, handler: async (ctx, args) => { const user = await Users.getCurrentUser(ctx); if (!user) return false; const userReviews = await ctx.db .query("reviews") .withIndex("by_user", (q) => q.eq("userId", user._id)) .collect(); return userReviews.some((r) => r.productId === args.productId); }, }); export const recalculate = internalMutation({ args: { productId: v.id("products") }, handler: async (ctx, args) => { await recalculateProductRating(ctx, args.productId); }, }); export const submitAndRecalculate = action({ args: { productId: v.id("products"), rating: v.number(), title: v.string(), content: v.string(), images: v.optional(v.array(v.string())), }, handler: async (ctx, args): Promise> => { const reviewId = (await ctx.runMutation(api.reviews.create, { productId: args.productId, rating: args.rating, title: args.title, content: args.content, images: args.images, })) as Id<"reviews">; await ctx.runMutation(internal.reviews.recalculate, { productId: args.productId, }); return reviewId; }, });