Files
the-pet-loft/convex/carts.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

238 lines
7.5 KiB
TypeScript

import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import * as Users from "./model/users";
import * as CartsModel from "./model/carts";
type EnrichedItem = {
variantId: string;
productId: string;
quantity: number;
priceSnapshot: number;
productName: string;
variantName: string;
imageUrl?: string;
stockQuantity?: number;
/** For PDP link: /shop/{parentCategorySlug}/{childCategorySlug}/{slug} */
productSlug?: string;
parentCategorySlug?: string;
childCategorySlug?: string;
};
async function enrichCartItems(
ctx: { db: import("./_generated/server").QueryCtx["db"] },
items: { productId: Id<"products">; variantId?: Id<"productVariants">; quantity: number; price: number }[]
): Promise<EnrichedItem[]> {
const enriched: EnrichedItem[] = [];
for (const item of items) {
const product = await ctx.db.get(item.productId);
const variant = item.variantId ? await ctx.db.get(item.variantId) : null;
if (!product || !variant) continue;
const images = await ctx.db
.query("productImages")
.withIndex("by_product", (q) => q.eq("productId", item.productId))
.collect();
images.sort((a, b) => a.position - b.position);
const imageUrl = images[0]?.url;
enriched.push({
variantId: item.variantId!,
productId: item.productId,
quantity: item.quantity,
priceSnapshot: item.price,
productName: product.name,
variantName: variant.name,
imageUrl,
stockQuantity: variant.stockQuantity,
productSlug: product.slug,
parentCategorySlug: product.parentCategorySlug,
childCategorySlug: product.childCategorySlug,
});
}
return enriched;
}
export const get = query({
args: { sessionId: v.optional(v.string()) },
handler: async (ctx, args) => {
const user = await Users.getCurrentUser(ctx);
const userId = user?._id ?? null;
const sessionId = args.sessionId;
if (!userId && !sessionId) return null;
const cart = await CartsModel.getCart(ctx, userId ?? undefined, sessionId);
if (!cart || cart.items.length === 0) {
return cart ? { ...cart, items: [] } : null;
}
const items = await enrichCartItems(ctx, cart.items);
return { ...cart, items };
},
});
export const addItem = mutation({
args: {
variantId: v.id("productVariants"),
quantity: v.number(),
sessionId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUser(ctx);
const userId = user?._id;
if (!userId && !args.sessionId) {
throw new Error("Must be authenticated or provide sessionId");
}
if (args.quantity < 1) throw new Error("Quantity must be at least 1");
const cart = await CartsModel.getOrCreateCart(ctx, userId, args.sessionId);
const variant = await ctx.db.get(args.variantId);
if (!variant) throw new Error("Variant not found");
if (!variant.isActive) throw new Error("Variant is not available");
const existingQty =
cart.items.find((i) => i.variantId === args.variantId)?.quantity ?? 0;
const newQty = existingQty + args.quantity;
if (variant.stockQuantity < newQty) {
throw new Error(
`Insufficient stock: only ${variant.stockQuantity} available`
);
}
const productId = variant.productId;
const newItems = [...cart.items];
const idx = newItems.findIndex((i) => i.variantId === args.variantId);
if (idx >= 0) {
newItems[idx] = {
...newItems[idx],
quantity: newItems[idx].quantity + args.quantity,
};
} else {
newItems.push({
productId,
variantId: args.variantId,
quantity: args.quantity,
price: variant.price,
});
}
await ctx.db.patch(cart._id, {
items: newItems,
updatedAt: Date.now(),
});
return cart._id;
},
});
export const updateItem = mutation({
args: {
variantId: v.id("productVariants"),
quantity: v.number(),
sessionId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUser(ctx);
const userId = user?._id;
if (!userId && !args.sessionId) {
throw new Error("Must be authenticated or provide sessionId");
}
const cart = await CartsModel.getOrCreateCart(ctx, userId, args.sessionId);
const idx = cart.items.findIndex((i) => i.variantId === args.variantId);
if (idx < 0) return cart._id;
if (args.quantity === 0) {
const newItems = cart.items.filter((i) => i.variantId !== args.variantId);
await ctx.db.patch(cart._id, { items: newItems, updatedAt: Date.now() });
return cart._id;
}
const variant = await ctx.db.get(args.variantId);
if (!variant) throw new Error("Variant not found");
if (variant.stockQuantity < args.quantity) {
throw new Error(
`Insufficient stock: only ${variant.stockQuantity} available`
);
}
const newItems = [...cart.items];
newItems[idx] = { ...newItems[idx], quantity: args.quantity };
await ctx.db.patch(cart._id, { items: newItems, updatedAt: Date.now() });
return cart._id;
},
});
export const removeItem = mutation({
args: {
variantId: v.id("productVariants"),
sessionId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUser(ctx);
const userId = user?._id;
if (!userId && !args.sessionId) {
throw new Error("Must be authenticated or provide sessionId");
}
const cart = await CartsModel.getOrCreateCart(ctx, userId, args.sessionId);
const newItems = cart.items.filter((i) => i.variantId !== args.variantId);
await ctx.db.patch(cart._id, { items: newItems, updatedAt: Date.now() });
return cart._id;
},
});
export const clear = mutation({
args: { sessionId: v.optional(v.string()) },
handler: async (ctx, args) => {
const user = await Users.getCurrentUser(ctx);
const userId = user?._id;
if (!userId && !args.sessionId) {
throw new Error("Must be authenticated or provide sessionId");
}
const cart = await CartsModel.getOrCreateCart(ctx, userId, args.sessionId);
await ctx.db.patch(cart._id, { items: [], updatedAt: Date.now() });
return cart._id;
},
});
export const merge = mutation({
args: { sessionId: v.optional(v.string()) },
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const userCart = await CartsModel.getOrCreateCart(ctx, user._id, undefined);
if (!args.sessionId) return userCart._id;
const guestCart = await ctx.db
.query("carts")
.withIndex("by_session", (q) => q.eq("sessionId", args.sessionId!))
.unique();
if (!guestCart || guestCart.items.length === 0) return userCart._id;
const mergedItems = [...userCart.items];
for (const guestItem of guestCart.items) {
const variantId = guestItem.variantId;
if (!variantId) continue;
const existing = mergedItems.find((i) => i.variantId === variantId);
if (existing) {
const variant = await ctx.db.get(variantId);
if (variant) {
const newQty = existing.quantity + guestItem.quantity;
const cap = Math.min(newQty, variant.stockQuantity);
existing.quantity = cap;
existing.price = variant.price;
}
} else {
mergedItems.push({ ...guestItem, variantId });
}
}
await ctx.db.patch(userCart._id, {
items: mergedItems,
updatedAt: Date.now(),
});
await ctx.db.patch(guestCart._id, { items: [], updatedAt: Date.now() });
return userCart._id;
},
});