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>
270 lines
7.9 KiB
TypeScript
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;
|
|
},
|
|
});
|