Files
the-pet-loft/convex/reviews.ts
ianshaloom cc15338ad9 feat: initial commit — storefront, convex backend, and shared packages
Completes the first milestone of The Pet Loft ecommerce platform:
- apps/storefront: full customer-facing Next.js app with HeroUI (cart,
  checkout, orders, wishlist, product detail, shop, search, auth)
- convex/: serverless backend with schema, queries, mutations, actions,
  HTTP routes, Stripe/Shippo integrations, and co-located tests
- packages/types, packages/utils, packages/convex: shared workspace packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:31:18 +03:00

270 lines
7.9 KiB
TypeScript

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<QueryCtx, "db">;
/**
* 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<boolean> {
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<Id<"reviews">> => {
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;
},
});