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>
This commit is contained in:
2026-03-04 09:31:18 +03:00
commit cc15338ad9
361 changed files with 45005 additions and 0 deletions

337
convex/addresses.test.ts Normal file
View File

@@ -0,0 +1,337 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
const shippingAddress = {
type: "shipping" as const,
firstName: "Alice",
lastName: "Smith",
phone: "+447911123456",
addressLine1: "10 Downing Street",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
};
describe("addresses", () => {
it("add sets default correctly and second add unsets previous default", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list1 = await asA.query(api.addresses.list, {});
expect(list1).toHaveLength(1);
expect(list1[0].isDefault).toBe(true);
await asA.mutation(api.addresses.add, {
...shippingAddress,
addressLine1: "221B Baker Street",
isDefault: true,
});
const list2 = await asA.query(api.addresses.list, {});
expect(list2).toHaveLength(2);
expect(list2[0].isDefault).toBe(true);
expect(list2[1].isDefault).toBe(false);
});
it("delete default address promotes next to default", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await asA.mutation(api.addresses.add, {
...shippingAddress,
addressLine1: "221B Baker Street",
isDefault: false,
});
const listBefore = await asA.query(api.addresses.list, {});
expect(listBefore).toHaveLength(2);
const defaultId = listBefore[0]._id;
await asA.mutation(api.addresses.remove, { id: defaultId });
const listAfter = await asA.query(api.addresses.list, {});
expect(listAfter).toHaveLength(1);
expect(listAfter[0].isDefault).toBe(true);
});
it("add derives fullName from firstName + lastName", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list[0].fullName).toBe("Alice Smith");
});
it("add persists additionalInformation when provided", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
additionalInformation: "Flat 4B",
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list[0].additionalInformation).toBe("Flat 4B");
});
it("add stores undefined for additionalInformation when not provided", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list[0].additionalInformation).toBeUndefined();
});
it("address records do not have state or addressLine2 fields", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
const addr = list[0] as Record<string, unknown>;
expect(addr).not.toHaveProperty("state");
expect(addr).not.toHaveProperty("addressLine2");
});
it("firstName and lastName are required and stored", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list[0].firstName).toBe("Alice");
expect(list[0].lastName).toBe("Smith");
});
it("isValidated defaults to false when passed via add mutation", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list).toHaveLength(1);
expect(list[0].isValidated).toBe(false);
});
it("add mutation persists isValidated: true when provided", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
isValidated: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list[0].isValidated).toBe(true);
});
it("update mutation patches isValidated", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
const id = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await asA.mutation(api.addresses.update, { id, isValidated: true });
const list1 = await asA.query(api.addresses.list, {});
expect(list1[0].isValidated).toBe(true);
await asA.mutation(api.addresses.update, { id, isValidated: false });
const list2 = await asA.query(api.addresses.list, {});
expect(list2[0].isValidated).toBe(false);
});
it("update mutation patches additionalInformation", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
const id = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await asA.mutation(api.addresses.update, { id, additionalInformation: "Floor 3" });
const list = await asA.query(api.addresses.list, {});
expect(list[0].additionalInformation).toBe("Floor 3");
});
it("markValidated sets isValidated to true", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
const id = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
expect((await asA.query(api.addresses.list, {}))[0].isValidated).toBe(false);
await asA.mutation(api.addresses.markValidated, { id, isValidated: true });
const list = await asA.query(api.addresses.list, {});
expect(list[0].isValidated).toBe(true);
});
it("markValidated throws if user does not own address", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
const asB = t.withIdentity({
name: "Bob",
email: "bob@example.com",
subject: "clerk_bob_456",
});
await asA.mutation(api.users.store, {});
await asB.mutation(api.users.store, {});
const id = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await expect(
asB.mutation(api.addresses.markValidated, { id, isValidated: true }),
).rejects.toThrow(/Unauthorized|does not belong/);
});
it("markValidated throws for non-existent address", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
const fakeId = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await asA.mutation(api.addresses.remove, { id: fakeId });
await expect(
asA.mutation(api.addresses.markValidated, { id: fakeId, isValidated: true }),
).rejects.toThrow(/not found/i);
});
it("update throws if user does not own the address", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
const asB = t.withIdentity({
name: "Bob",
email: "bob@example.com",
subject: "clerk_bob_456",
});
await asA.mutation(api.users.store, {});
await asB.mutation(api.users.store, {});
const id = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await expect(
asB.mutation(api.addresses.update, { id, fullName: "Hacker" }),
).rejects.toThrow(/Unauthorized|does not belong/);
await asA.mutation(api.addresses.update, { id, fullName: "Alice Updated" });
const list = await asA.query(api.addresses.list, {});
expect(list[0].fullName).toBe("Alice Updated");
});
});

168
convex/addresses.ts Normal file
View File

@@ -0,0 +1,168 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import * as Users from "./model/users";
const addressTypeValidator = v.union(
v.literal("shipping"),
v.literal("billing"),
);
const addressFieldsValidator = {
type: addressTypeValidator,
fullName: v.optional(v.string()),
firstName: v.string(),
lastName: v.string(),
phone: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
isDefault: v.boolean(),
isValidated: v.optional(v.boolean()),
};
export const list = query({
args: {},
handler: async (ctx) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const addresses = await ctx.db
.query("addresses")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.collect();
addresses.sort((a, b) => {
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
return b._creationTime - a._creationTime;
});
return addresses;
},
});
export const add = mutation({
args: addressFieldsValidator,
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const existingOfType = await ctx.db
.query("addresses")
.withIndex("by_user_and_type", (q) =>
q.eq("userId", user._id).eq("type", args.type),
)
.collect();
const isDefault = existingOfType.length === 0 ? true : args.isDefault;
if (isDefault && existingOfType.length > 0) {
for (const addr of existingOfType) {
await ctx.db.patch(addr._id, { isDefault: false });
}
}
return await ctx.db.insert("addresses", {
userId: user._id,
type: args.type,
fullName: args.fullName || `${args.firstName} ${args.lastName}`,
firstName: args.firstName,
lastName: args.lastName,
phone: args.phone,
addressLine1: args.addressLine1,
additionalInformation: args.additionalInformation,
city: args.city,
postalCode: args.postalCode,
country: args.country,
isDefault,
isValidated: args.isValidated ?? false,
});
},
});
export const update = mutation({
args: {
id: v.id("addresses"),
type: v.optional(addressTypeValidator),
fullName: v.optional(v.string()),
firstName: v.optional(v.string()),
lastName: v.optional(v.string()),
phone: v.optional(v.string()),
addressLine1: v.optional(v.string()),
additionalInformation: v.optional(v.string()),
city: v.optional(v.string()),
postalCode: v.optional(v.string()),
country: v.optional(v.string()),
isDefault: v.optional(v.boolean()),
isValidated: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const { id, ...updates } = args;
const address = await ctx.db.get(id);
if (!address) throw new Error("Address not found");
await Users.requireOwnership(ctx, address.userId);
const patch: Record<string, unknown> = {};
if (updates.type !== undefined) patch.type = updates.type;
if (updates.fullName !== undefined) patch.fullName = updates.fullName;
if (updates.firstName !== undefined) patch.firstName = updates.firstName;
if (updates.lastName !== undefined) patch.lastName = updates.lastName;
if (updates.phone !== undefined) patch.phone = updates.phone;
if (updates.addressLine1 !== undefined)
patch.addressLine1 = updates.addressLine1;
if (updates.additionalInformation !== undefined)
patch.additionalInformation = updates.additionalInformation;
if (updates.city !== undefined) patch.city = updates.city;
if (updates.postalCode !== undefined) patch.postalCode = updates.postalCode;
if (updates.country !== undefined) patch.country = updates.country;
if (updates.isDefault !== undefined) patch.isDefault = updates.isDefault;
if (updates.isValidated !== undefined)
patch.isValidated = updates.isValidated;
if (Object.keys(patch).length === 0) return id;
await ctx.db.patch(id, patch as any);
return id;
},
});
export const remove = mutation({
args: { id: v.id("addresses") },
handler: async (ctx, { id }) => {
const address = await ctx.db.get(id);
if (!address) throw new Error("Address not found");
await Users.requireOwnership(ctx, address.userId);
if (address.isDefault) {
const rest = await ctx.db
.query("addresses")
.withIndex("by_user", (q) => q.eq("userId", address.userId))
.filter((q) => q.neq(q.field("_id"), id))
.collect();
const first = rest[0];
if (first) await ctx.db.patch(first._id, { isDefault: true });
}
await ctx.db.delete(id);
},
});
export const setDefault = mutation({
args: { id: v.id("addresses") },
handler: async (ctx, { id }) => {
const address = await ctx.db.get(id);
if (!address) throw new Error("Address not found");
await Users.requireOwnership(ctx, address.userId);
const all = await ctx.db
.query("addresses")
.withIndex("by_user", (q) => q.eq("userId", address.userId))
.collect();
for (const addr of all) {
await ctx.db.patch(addr._id, { isDefault: addr._id === id });
}
},
});
export const markValidated = mutation({
args: {
id: v.id("addresses"),
isValidated: v.boolean(),
},
handler: async (ctx, { id, isValidated }) => {
const address = await ctx.db.get(id);
if (!address) throw new Error("Address not found");
await Users.requireOwnership(ctx, address.userId);
await ctx.db.patch(id, { isValidated });
},
});

10
convex/auth.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { AuthConfig } from "convex/server";
export default {
providers: [
{
domain: process.env.CLERK_STOREFRONT_JWT_ISSUER_DOMAIN!,
applicationID: "convex",
},
],
} satisfies AuthConfig;

121
convex/carts.test.ts Normal file
View File

@@ -0,0 +1,121 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
async function setupUserAndVariant(t: ReturnType<typeof convexTest>) {
const asCustomer = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asCustomer.mutation(api.users.store, {});
let categoryId: any;
let variantId: any;
await t.run(async (ctx) => {
categoryId = await ctx.db.insert("categories", {
name: "Toys",
slug: "toys",
});
const productId = await ctx.db.insert("products", {
name: "Ball",
slug: "ball",
status: "active",
categoryId,
tags: [],
});
variantId = await ctx.db.insert("productVariants", {
productId,
name: "Red Ball",
sku: "BALL-RED-001",
price: 999,
stockQuantity: 50,
attributes: { color: "Red" },
isActive: true,
});
});
return { asCustomer, variantId };
}
describe("carts", () => {
it("addItem adds a line; second addItem with same variantId increases quantity", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId } = await setupUserAndVariant(t);
await asCustomer.mutation(api.carts.addItem, {
variantId,
quantity: 2,
});
let cart = await asCustomer.query(api.carts.get, {});
expect(cart).not.toBeNull();
expect(cart!.items).toHaveLength(1);
expect(cart!.items[0].quantity).toBe(2);
await asCustomer.mutation(api.carts.addItem, {
variantId,
quantity: 3,
});
cart = await asCustomer.query(api.carts.get, {});
expect(cart!.items).toHaveLength(1);
expect(cart!.items[0].quantity).toBe(5);
});
it("updateItem with quantity 0 removes the line", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId } = await setupUserAndVariant(t);
await asCustomer.mutation(api.carts.addItem, { variantId, quantity: 1 });
await asCustomer.mutation(api.carts.updateItem, {
variantId,
quantity: 0,
});
const cart = await asCustomer.query(api.carts.get, {});
expect(cart!.items).toHaveLength(0);
});
it("clear empties the cart", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId } = await setupUserAndVariant(t);
await asCustomer.mutation(api.carts.addItem, { variantId, quantity: 2 });
await asCustomer.mutation(api.carts.clear, {});
const cart = await asCustomer.query(api.carts.get, {});
expect(cart!.items).toHaveLength(0);
});
it("guest cart with sessionId; merge into authenticated user cart", async () => {
const t = convexTest(schema, modules);
const { variantId } = await setupUserAndVariant(t);
const sessionId = "guest-session-123";
await t.mutation(api.carts.addItem, {
variantId,
quantity: 2,
sessionId,
});
const guestCart = await t.query(api.carts.get, { sessionId });
expect(guestCart).not.toBeNull();
expect(guestCart!.items).toHaveLength(1);
expect(guestCart!.items[0].quantity).toBe(2);
const asCustomer = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asCustomer.mutation(api.users.store, {});
await asCustomer.mutation(api.carts.addItem, { variantId, quantity: 1 });
await asCustomer.mutation(api.carts.merge, { sessionId });
const userCart = await asCustomer.query(api.carts.get, {});
expect(userCart!.items).toHaveLength(1);
expect(userCart!.items[0].quantity).toBe(3);
const guestCartAfter = await t.query(api.carts.get, { sessionId });
expect(guestCartAfter!.items).toHaveLength(0);
});
});

237
convex/carts.ts Normal file
View File

@@ -0,0 +1,237 @@
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;
},
});

124
convex/categories.test.ts Normal file
View File

@@ -0,0 +1,124 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
async function setupAdminUser(t: ReturnType<typeof convexTest>) {
const asAdmin = t.withIdentity({
name: "Admin",
email: "admin@example.com",
subject: "clerk_admin_123",
});
const userId = await asAdmin.mutation(api.users.store, {});
await t.run(async (ctx) => {
await ctx.db.patch(userId, { role: "admin" });
});
return asAdmin;
}
describe("categories", () => {
it("list returns all categories ordered by name", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
await asAdmin.mutation(api.categories.create, {
name: "Zebra",
slug: "zebra",
});
await asAdmin.mutation(api.categories.create, {
name: "Alpha",
slug: "alpha",
});
const list = await t.query(api.categories.list, {});
expect(list).toHaveLength(2);
expect(list[0].name).toBe("Alpha");
expect(list[1].name).toBe("Zebra");
});
it("list with parentId returns only children of that parent", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const parentId = await asAdmin.mutation(api.categories.create, {
name: "Parent",
slug: "parent",
});
await asAdmin.mutation(api.categories.create, {
name: "Child A",
slug: "child-a",
parentId,
});
await asAdmin.mutation(api.categories.create, {
name: "Child B",
slug: "child-b",
parentId,
});
await asAdmin.mutation(api.categories.create, {
name: "Other",
slug: "other",
});
const children = await t.query(api.categories.list, { parentId });
expect(children).toHaveLength(2);
expect(children.map((c) => c.name).sort()).toEqual(["Child A", "Child B"]);
});
it("getBySlug returns category when slug exists", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
await asAdmin.mutation(api.categories.create, {
name: "Pet Food",
slug: "pet-food",
});
const category = await t.query(api.categories.getBySlug, {
slug: "pet-food",
});
expect(category).not.toBeNull();
expect(category?.name).toBe("Pet Food");
expect(category?.slug).toBe("pet-food");
});
it("getBySlug returns null for unknown slug", async () => {
const t = convexTest(schema, modules);
const category = await t.query(api.categories.getBySlug, {
slug: "does-not-exist",
});
expect(category).toBeNull();
});
it("create succeeds for admin users", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const id = await asAdmin.mutation(api.categories.create, {
name: "Toys",
slug: "toys",
});
expect(id).toBeTruthy();
const category = await t.query(api.categories.getBySlug, { slug: "toys" });
expect(category?.name).toBe("Toys");
});
it("create throws for non-admin users", async () => {
const t = convexTest(schema, modules);
const asCustomer = t.withIdentity({
name: "Customer",
email: "customer@example.com",
subject: "clerk_customer_123",
});
await asCustomer.mutation(api.users.store, {});
await expect(
asCustomer.mutation(api.categories.create, {
name: "Illegal",
slug: "illegal",
}),
).rejects.toThrow("Unauthorized: admin access required");
});
});

120
convex/categories.ts Normal file
View File

@@ -0,0 +1,120 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import * as Users from "./model/users";
import * as Categories from "./model/categories";
export const list = query({
args: {
parentId: v.optional(v.id("categories")),
},
handler: async (ctx, args) => {
let items;
console.log("args in list", args);
if (args.parentId !== undefined) {
items = await ctx.db
.query("categories")
.withIndex("by_parent", (q) => q.eq("parentId", args.parentId!))
.collect();
} else {
items = await ctx.db.query("categories").collect();
}
items.sort((a, b) => a.name.localeCompare(b.name));
return items;
},
});
export const getById = query({
args: { id: v.id("categories") },
handler: async (ctx, { id }) => {
return await ctx.db.get(id);
},
});
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, { slug }) => {
return await ctx.db
.query("categories")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.unique();
},
});
export const getByPath = query({
args: {
categorySlug: v.string(),
subCategorySlug: v.string(),
},
handler: async (ctx, { categorySlug, subCategorySlug }) => {
const parent = await ctx.db
.query("categories")
.withIndex("by_slug", (q) => q.eq("slug", categorySlug))
.unique();
if (!parent) return null;
return await ctx.db
.query("categories")
.withIndex("by_parent_slug", (q) =>
q.eq("parentId", parent._id).eq("slug", subCategorySlug),
)
.unique();
},
});
export const listByTopCategory = query({
args: { slug: v.string() },
handler: async (ctx, { slug }) => {
const items = await ctx.db
.query("categories")
.withIndex("by_top_category_slug", (q) =>
q.eq("topCategorySlug", slug),
)
.collect();
items.sort((a, b) => a.name.localeCompare(b.name));
return items;
},
});
export const create = mutation({
args: {
name: v.string(),
slug: v.string(),
description: v.optional(v.string()),
parentId: v.optional(v.id("categories")),
topCategorySlug: v.optional(v.string()),
seoTitle: v.optional(v.string()),
seoDescription: v.optional(v.string()),
},
handler: async (ctx, args) => {
await Users.requireAdmin(ctx);
const existing = await ctx.db
.query("categories")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.unique();
if (existing) throw new Error("Category slug already exists");
return await ctx.db.insert("categories", args);
},
});
export const update = mutation({
args: {
id: v.id("categories"),
name: v.optional(v.string()),
slug: v.optional(v.string()),
description: v.optional(v.string()),
topCategorySlug: v.optional(v.string()),
seoTitle: v.optional(v.string()),
seoDescription: v.optional(v.string()),
},
handler: async (ctx, { id, ...updates }) => {
await Users.requireAdmin(ctx);
await Categories.getCategoryOrThrow(ctx, id);
const fields: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) fields[key] = value;
}
if (Object.keys(fields).length > 0) {
await ctx.db.patch(id, fields);
}
return id;
},
});

447
convex/checkout.test.ts Normal file
View File

@@ -0,0 +1,447 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api, internal } from "./_generated/api";
import schema from "./schema";
import type { Id } from "./_generated/dataModel";
const modules = import.meta.glob("./**/*.ts");
const identity = {
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
};
const baseAddress = {
firstName: "Alice",
lastName: "Smith",
phone: "+447911123456",
addressLine1: "10 Downing Street",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
};
describe("checkout.getShippingAddresses", () => {
it("returns only shipping addresses (excludes billing)", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "billing",
addressLine1: "1 Treasury Place",
isDefault: false,
});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "221B Baker Street",
isDefault: false,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toHaveLength(2);
expect(result.every((a) => a.type === "shipping")).toBe(true);
});
it("sorts default address first, then by recency descending", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "First added",
isDefault: false,
});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "Second added (default)",
isDefault: true,
});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "Third added",
isDefault: false,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toHaveLength(3);
expect(result[0].addressLine1).toBe("Second added (default)");
expect(result[0].isDefault).toBe(true);
expect(result[1].addressLine1).toBe("Third added");
expect(result[2].addressLine1).toBe("First added");
});
it("returns empty array when user has no shipping addresses", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toEqual([]);
});
it("returns empty array when user only has billing addresses", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "billing",
isDefault: true,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toEqual([]);
});
it("includes isValidated field on returned addresses", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
isValidated: true,
});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "221B Baker Street",
isDefault: false,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toHaveLength(2);
expect(result[0].isValidated).toBe(true);
expect(result[1].isValidated).toBe(false);
});
it("includes additionalInformation when present", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
additionalInformation: "Flat 4B",
isDefault: true,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toHaveLength(1);
expect(result[0].additionalInformation).toBe("Flat 4B");
});
it("returned addresses have no state or addressLine2 fields", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
const addr = result[0] as Record<string, unknown>;
expect(addr).not.toHaveProperty("state");
expect(addr).not.toHaveProperty("addressLine2");
});
it("throws for unauthenticated users", async () => {
const t = convexTest(schema, modules);
await expect(
t.query(api.checkout.getShippingAddresses, {}),
).rejects.toThrow(/Unauthenticated/i);
});
it("does not return addresses belonging to other users", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const asB = t.withIdentity({
name: "Bob",
email: "bob@example.com",
subject: "clerk_bob_456",
});
await asA.mutation(api.users.store, {});
await asB.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await asB.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "Bob's address",
isDefault: true,
});
const resultA = await asA.query(api.checkout.getShippingAddresses, {});
expect(resultA).toHaveLength(1);
expect(resultA[0].addressLine1).toBe("10 Downing Street");
const resultB = await asB.query(api.checkout.getShippingAddresses, {});
expect(resultB).toHaveLength(1);
expect(resultB[0].addressLine1).toBe("Bob's address");
});
});
// ─── getAddressById (internal query) ─────────────────────────────────────────
describe("checkout.getAddressById", () => {
it("returns the address document when it exists", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
const result = await t.query(internal.checkout.getAddressById, {
addressId,
});
expect(result.addressLine1).toBe("10 Downing Street");
expect(result.fullName).toBe("Alice Smith");
expect(result.city).toBe("London");
expect(result.postalCode).toBe("SW1A 2AA");
expect(result.country).toBe("GB");
});
it("throws when the address does not exist", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await asA.mutation(api.addresses.remove, { id: addressId });
await expect(
t.query(internal.checkout.getAddressById, { addressId }),
).rejects.toThrow(/Address not found/i);
});
it("returns additionalInformation when present on address", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
additionalInformation: "Flat 4B",
isDefault: true,
});
const result = await t.query(internal.checkout.getAddressById, {
addressId,
});
expect(result.additionalInformation).toBe("Flat 4B");
});
});
// ─── getCurrentUserId (internal query) ───────────────────────────────────────
describe("checkout.getCurrentUserId", () => {
it("returns the user ID for an authenticated user", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
const result = await asA.query(internal.checkout.getCurrentUserId);
expect(result).toBe(userId);
});
it("throws for unauthenticated requests", async () => {
const t = convexTest(schema, modules);
await expect(
t.query(internal.checkout.getCurrentUserId),
).rejects.toThrow(/Unauthenticated/i);
});
});
// ─── validateCartInternal (internal query) ───────────────────────────────────
describe("checkout.validateCartInternal", () => {
async function setupProductAndVariant(
t: ReturnType<typeof convexTest>,
overrides?: { stockQuantity?: number; price?: number; isActive?: boolean },
) {
let productId: Id<"products">;
let variantId: Id<"productVariants">;
let categoryId: Id<"categories">;
await t.run(async (ctx) => {
categoryId = await ctx.db.insert("categories", {
name: "Dog Food",
slug: "dog-food",
});
productId = await ctx.db.insert("products", {
name: "Premium Kibble",
slug: "premium-kibble",
status: "active",
categoryId,
tags: [],
parentCategorySlug: "dogs",
childCategorySlug: "dog-food",
});
variantId = await ctx.db.insert("productVariants", {
productId,
name: "1kg Bag",
sku: "PK-001",
price: overrides?.price ?? 2499,
stockQuantity: overrides?.stockQuantity ?? 50,
isActive: overrides?.isActive ?? true,
weight: 1000,
weightUnit: "g",
length: 30,
width: 20,
height: 10,
dimensionUnit: "cm",
});
await ctx.db.insert("productImages", {
productId,
url: "https://example.com/kibble.jpg",
position: 0,
});
});
return { productId: productId!, variantId: variantId!, categoryId: categoryId! };
}
it("returns null when the user has no cart", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
const result = await t.query(internal.checkout.validateCartInternal, {
userId,
});
expect(result).toBeNull();
});
it("returns null when the cart has no items", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
await t.run(async (ctx) => {
await ctx.db.insert("carts", {
userId,
items: [],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
const result = await t.query(internal.checkout.validateCartInternal, {
userId,
});
expect(result).toBeNull();
});
it("returns a valid cart validation result with enriched items", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
const { productId, variantId } = await setupProductAndVariant(t);
await t.run(async (ctx) => {
await ctx.db.insert("carts", {
userId,
items: [{ productId, variantId, quantity: 2, price: 2499 }],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
const result = await t.query(internal.checkout.validateCartInternal, {
userId,
});
expect(result).not.toBeNull();
expect(result!.valid).toBe(true);
expect(result!.items).toHaveLength(1);
expect(result!.items[0].productName).toBe("Premium Kibble");
expect(result!.items[0].variantName).toBe("1kg Bag");
expect(result!.items[0].quantity).toBe(2);
expect(result!.items[0].weight).toBe(1000);
expect(result!.items[0].weightUnit).toBe("g");
expect(result!.subtotal).toBe(2499 * 2);
});
it("returns issues for out-of-stock items", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
const { productId, variantId } = await setupProductAndVariant(t, {
stockQuantity: 0,
});
await t.run(async (ctx) => {
await ctx.db.insert("carts", {
userId,
items: [{ productId, variantId, quantity: 1, price: 2499 }],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
const result = await t.query(internal.checkout.validateCartInternal, {
userId,
});
expect(result).not.toBeNull();
expect(result!.valid).toBe(false);
expect(result!.issues).toHaveLength(1);
expect(result!.issues[0].type).toBe("out_of_stock");
});
it("resolves cart by sessionId when userId not provided", async () => {
const t = convexTest(schema, modules);
const { productId, variantId } = await setupProductAndVariant(t);
await t.run(async (ctx) => {
await ctx.db.insert("carts", {
sessionId: "sess_abc123",
items: [{ productId, variantId, quantity: 1, price: 2499 }],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
const result = await t.query(internal.checkout.validateCartInternal, {
sessionId: "sess_abc123",
});
expect(result).not.toBeNull();
expect(result!.valid).toBe(true);
expect(result!.items).toHaveLength(1);
});
});

92
convex/checkout.ts Normal file
View File

@@ -0,0 +1,92 @@
import { query, internalQuery } from "./_generated/server";
import { v } from "convex/values";
import * as Users from "./model/users";
import * as CartsModel from "./model/carts";
import { validateAndEnrichCart } from "./model/checkout";
import type { CartValidationResult } from "./model/checkout";
const EMPTY_RESULT_INVALID: CartValidationResult = {
valid: false,
items: [],
issues: [],
subtotal: 0,
};
const EMPTY_RESULT_VALID: CartValidationResult = {
valid: true,
items: [],
issues: [],
subtotal: 0,
};
export const validateCart = query({
args: { sessionId: v.optional(v.string()) },
handler: async (ctx, args) => {
const user = await Users.getCurrentUser(ctx);
const userId = user?._id ?? null;
if (!userId && !args.sessionId) {
return EMPTY_RESULT_INVALID;
}
const cart = await CartsModel.getCart(ctx, userId ?? undefined, args.sessionId);
if (!cart || cart.items.length === 0) {
return EMPTY_RESULT_VALID;
}
return await validateAndEnrichCart(ctx, cart.items);
},
});
export const getShippingAddresses = query({
args: {},
handler: async (ctx) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const addresses = await ctx.db
.query("addresses")
.withIndex("by_user_and_type", (q) =>
q.eq("userId", user._id).eq("type", "shipping"),
)
.collect();
addresses.sort((a, b) => {
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
return b._creationTime - a._creationTime;
});
return addresses;
},
});
// ─── Internal Queries (consumed by actions in checkoutActions.ts) ────────────
export const getAddressById = internalQuery({
args: { addressId: v.id("addresses") },
handler: async (ctx, args) => {
const address = await ctx.db.get(args.addressId);
if (!address) throw new Error("Address not found");
return address;
},
});
export const validateCartInternal = internalQuery({
args: {
userId: v.optional(v.id("users")),
sessionId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const cart = await CartsModel.getCart(
ctx,
args.userId ?? undefined,
args.sessionId,
);
if (!cart || cart.items.length === 0) return null;
return await validateAndEnrichCart(ctx, cart.items);
},
});
export const getCurrentUserId = internalQuery({
args: {},
handler: async (ctx) => {
const user = await Users.getCurrentUserOrThrow(ctx);
return user._id;
},
});

View File

@@ -0,0 +1,331 @@
import { convexTest } from "convex-test";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
import type { Id } from "./_generated/dataModel";
const modules = import.meta.glob("./**/*.ts");
const identity = {
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
};
const baseAddress = {
firstName: "Alice",
lastName: "Smith",
phone: "+447911123456",
addressLine1: "10 Downing Street",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
};
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
vi.stubEnv("SHIPPO_API_KEY", "shippo_test_key_123");
vi.stubEnv("SHIPPO_SOURCE_ADDRESS_ID", "addr_warehouse_001");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
function mockShippoShipmentResponse(overrides?: {
shipmentId?: string;
rates?: Array<{
object_id: string;
provider: string;
servicelevel: { name: string; token: string };
amount: string;
currency: string;
estimated_days: number;
duration_terms: string;
arrives_by?: string | null;
carrier_account: string;
}>;
}) {
const defaultRates = [
{
object_id: "rate_001",
provider: "DPD UK",
servicelevel: { name: "Next Day", token: "dpd_uk_next_day" },
amount: "5.50",
currency: "GBP",
estimated_days: 1,
duration_terms: "1-2 business days",
arrives_by: null,
carrier_account: "ca_dpd_001",
},
{
object_id: "rate_002",
provider: "UPS",
servicelevel: { name: "Standard", token: "ups_standard" },
amount: "7.99",
currency: "GBP",
estimated_days: 3,
duration_terms: "3-5 business days",
carrier_account: "ca_ups_001",
},
];
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
object_id: overrides?.shipmentId ?? "shp_test_123",
rates: overrides?.rates ?? defaultRates,
}),
});
}
async function setupFullCheckoutContext(
t: ReturnType<typeof convexTest>,
overrides?: { stockQuantity?: number; price?: number; isActive?: boolean },
) {
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
additionalInformation: "Flat 2",
isDefault: true,
isValidated: true,
});
let productId: Id<"products">;
let variantId: Id<"productVariants">;
await t.run(async (ctx) => {
const categoryId = await ctx.db.insert("categories", {
name: "Dog Food",
slug: "dog-food",
});
productId = await ctx.db.insert("products", {
name: "Premium Kibble",
slug: "premium-kibble",
status: "active",
categoryId,
tags: [],
parentCategorySlug: "dogs",
childCategorySlug: "dog-food",
});
variantId = await ctx.db.insert("productVariants", {
productId,
name: "1kg Bag",
sku: "PK-001",
price: overrides?.price ?? 2499,
stockQuantity: overrides?.stockQuantity ?? 50,
isActive: overrides?.isActive ?? true,
weight: 1000,
weightUnit: "g",
length: 30,
width: 20,
height: 10,
dimensionUnit: "cm",
});
await ctx.db.insert("productImages", {
productId,
url: "https://example.com/kibble.jpg",
position: 0,
});
await ctx.db.insert("carts", {
userId,
items: [
{
productId,
variantId,
quantity: 2,
price: overrides?.price ?? 2499,
},
],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
return { userId, addressId, productId: productId!, variantId: variantId!, asA };
}
describe("checkoutActions.getShippingRate", () => {
it("throws when user is not authenticated", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await expect(
t.action(api.checkoutActions.getShippingRate, { addressId }),
).rejects.toThrow(/signed in/i);
});
it("throws when cart is empty", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await expect(
asA.action(api.checkoutActions.getShippingRate, { addressId }),
).rejects.toThrow(/cart is empty/i);
});
it("throws when cart has blocking issues (out of stock)", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t, {
stockQuantity: 0,
});
mockShippoShipmentResponse();
await expect(
asA.action(api.checkoutActions.getShippingRate, { addressId }),
).rejects.toThrow(/cart has issues/i);
});
it("returns a complete ShippingRateResult on success", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
mockShippoShipmentResponse();
const result = await asA.action(api.checkoutActions.getShippingRate, {
addressId,
});
expect(result.shipmentObjectId).toBe("shp_test_123");
expect(result.selectedRate.provider).toBe("DPD UK");
expect(result.selectedRate.serviceName).toBe("Next Day");
expect(result.selectedRate.serviceToken).toBe("dpd_uk_next_day");
expect(result.selectedRate.amount).toBe(5.5);
expect(result.selectedRate.currency).toBe("GBP");
expect(result.selectedRate.estimatedDays).toBe(1);
expect(result.selectedRate.durationTerms).toBe("1-2 business days");
expect(result.selectedRate.carrierAccount).toBe("ca_dpd_001");
expect(result.alternativeRates).toHaveLength(1);
expect(result.alternativeRates[0].provider).toBe("UPS");
expect(result.cartSubtotal).toBe(2499 * 2);
expect(result.shippingTotal).toBe(5.5);
expect(result.orderTotal).toBe(2499 * 2 + 5.5);
});
it("sends correct Shippo request with mapped address fields", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
mockShippoShipmentResponse();
await asA.action(api.checkoutActions.getShippingRate, { addressId });
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, opts] = fetchSpy.mock.calls[0];
expect(url).toBe("https://api.goshippo.com/shipments/");
expect(opts.method).toBe("POST");
const body = JSON.parse(opts.body);
expect(body.address_from).toBe("addr_warehouse_001");
expect(body.address_to.name).toBe("Alice Smith");
expect(body.address_to.street1).toBe("10 Downing Street");
expect(body.address_to.street2).toBe("Flat 2");
expect(body.address_to.city).toBe("London");
expect(body.address_to.zip).toBe("SW1A 2AA");
expect(body.address_to.country).toBe("GB");
expect(body.address_to.phone).toBe("+447911123456");
expect(body.async).toBe(false);
expect(body.parcels).toHaveLength(1);
expect(body.parcels[0].weight).toBe("2000");
expect(body.parcels[0].mass_unit).toBe("g");
});
it("throws when SHIPPO_SOURCE_ADDRESS_ID env var is missing", async () => {
vi.stubEnv("SHIPPO_SOURCE_ADDRESS_ID", "");
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await expect(
asA.action(api.checkoutActions.getShippingRate, { addressId }),
).rejects.toThrow(/missing source address/i);
});
it("throws when Shippo API returns no rates", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
mockShippoShipmentResponse({ rates: [] });
await expect(
asA.action(api.checkoutActions.getShippingRate, { addressId }),
).rejects.toThrow(/no shipping rates available/i);
});
it("falls back to non-preferred carriers when no preferred carriers in response", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
mockShippoShipmentResponse({
rates: [
{
object_id: "rate_non_pref",
provider: "Royal Mail",
servicelevel: { name: "2nd Class", token: "royal_mail_2nd" },
amount: "3.00",
currency: "GBP",
estimated_days: 3,
duration_terms: "2-4 business days",
arrives_by: null,
carrier_account: "ca_rm_001",
},
],
});
const result = await asA.action(api.checkoutActions.getShippingRate, {
addressId,
});
expect(result.selectedRate.provider).toBe("Royal Mail");
expect(result.selectedRate.amount).toBe(3.0);
expect(warnSpy).toHaveBeenCalledWith(
"No preferred carriers returned rates. Falling back to all carriers.",
);
warnSpy.mockRestore();
});
it("correctly computes parcel from cart items with dimensions", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
mockShippoShipmentResponse();
await asA.action(api.checkoutActions.getShippingRate, { addressId });
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
const parcel = body.parcels[0];
expect(parcel.length).toBe("30");
expect(parcel.width).toBe("20");
expect(parcel.height).toBe("20");
expect(parcel.distance_unit).toBe("cm");
});
});

136
convex/checkoutActions.ts Normal file
View File

@@ -0,0 +1,136 @@
"use node";
import { action } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import {
validateAddressWithShippo,
computeParcel,
getShippingRatesFromShippo,
selectBestRate,
MAX_PARCEL_WEIGHT_G,
} from "./model/shippo";
import type { ShippingRateResult } from "./model/checkout";
export const validateAddress = action({
args: {
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
name: v.optional(v.string()),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("You must be signed in to validate an address.");
}
return await validateAddressWithShippo({
addressLine1: args.addressLine1,
additionalInformation: args.additionalInformation,
city: args.city,
postalCode: args.postalCode,
country: args.country,
name: args.name,
});
},
});
export const getShippingRate = action({
args: {
addressId: v.id("addresses"),
sessionId: v.optional(v.string()),
},
handler: async (ctx, args): Promise<ShippingRateResult> => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("You must be signed in to get shipping rates.");
}
const address = await ctx.runQuery(internal.checkout.getAddressById, {
addressId: args.addressId,
});
if (address.isValidated !== true) {
console.warn(
`Shipping rate requested for unvalidated address ${args.addressId}. ` +
"The shipping address step should validate before proceeding.",
);
}
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
const cartResult = await ctx.runQuery(
internal.checkout.validateCartInternal,
{ userId, sessionId: args.sessionId },
);
if (!cartResult) {
throw new ConvexError("Your cart is empty.");
}
if (!cartResult.valid) {
throw new ConvexError(
"Your cart has issues that need to be resolved before checkout.",
);
}
const parcel = computeParcel(cartResult.items);
const weightG = parseFloat(parcel.weight);
if (weightG > MAX_PARCEL_WEIGHT_G) {
const actualKg = (weightG / 1000).toFixed(1);
const maxKg = MAX_PARCEL_WEIGHT_G / 1000;
throw new ConvexError(
`Your order weighs ${actualKg}kg, which exceeds our maximum shipping weight of ${maxKg}kg. ` +
"Please remove some items or reduce quantities to proceed.",
);
}
const shippoAddress = {
name: address.fullName,
street1: address.addressLine1,
street2: address.additionalInformation,
city: address.city,
zip: address.postalCode,
country: address.country,
phone: address.phone,
};
const sourceAddressId = process.env.SHIPPO_SOURCE_ADDRESS_ID;
if (!sourceAddressId) {
throw new ConvexError(
"Shipping configuration is incomplete (missing source address).",
);
}
const { shipmentObjectId, rates } = await getShippingRatesFromShippo({
sourceAddressId,
destinationAddress: shippoAddress,
parcels: [parcel],
});
const { selected, alternatives } = selectBestRate(rates);
const mapRate = (r: typeof selected) => ({
provider: r.provider,
serviceName: r.servicelevelName,
serviceToken: r.servicelevelToken,
amount: parseFloat(r.amount),
currency: r.currency,
estimatedDays: r.estimatedDays,
durationTerms: r.durationTerms,
carrierAccount: r.carrierAccount,
});
return {
shipmentObjectId,
selectedRate: mapRate(selected),
alternativeRates: alternatives.map(mapRate),
cartSubtotal: cartResult.subtotal,
shippingTotal: parseFloat(selected.amount),
orderTotal: cartResult.subtotal + parseFloat(selected.amount),
};
},
});

80
convex/http.ts Normal file
View File

@@ -0,0 +1,80 @@
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import type { WebhookEvent } from "@clerk/backend";
import { Webhook } from "svix";
const http = httpRouter();
http.route({
path: "/clerk-users-webhook",
method: "POST",
handler: httpAction(async (ctx, request) => {
const event = await validateRequest(request);
if (!event) return new Response("Error", { status: 400 });
switch (event.type) {
case "user.created":
case "user.updated":
await ctx.runMutation(internal.users.upsertFromClerk, {
externalId: event.data.id,
name:
`${event.data.first_name ?? ""} ${event.data.last_name ?? ""}`.trim(),
email: event.data.email_addresses[0]?.email_address ?? "",
avatarUrl: event.data.image_url ?? undefined,
});
break;
case "user.deleted":
if (event.data.id) {
await ctx.runMutation(internal.users.deleteFromClerk, {
externalId: event.data.id,
});
}
break;
default:
console.log("Ignored webhook event:", event.type);
}
return new Response(null, { status: 200 });
}),
});
async function validateRequest(
req: Request,
): Promise<WebhookEvent | null> {
const payload = await req.text();
const headers = {
"svix-id": req.headers.get("svix-id")!,
"svix-timestamp": req.headers.get("svix-timestamp")!,
"svix-signature": req.headers.get("svix-signature")!,
};
try {
return new Webhook(process.env.CLERK_WEBHOOK_SECRET!).verify(
payload,
headers,
) as WebhookEvent;
} catch {
return null;
}
}
http.route({
path: "/stripe/webhook",
method: "POST",
handler: httpAction(async (ctx, request) => {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing stripe-signature header", { status: 400 });
}
const result = await ctx.runAction(internal.stripeActions.handleWebhook, {
payload: body,
signature,
});
return new Response(null, { status: result.success ? 200 : 400 });
}),
});
export default http;

80
convex/model/carts.ts Normal file
View File

@@ -0,0 +1,80 @@
import { MutationCtx, QueryCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";
const CART_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
type CartReadCtx = Pick<QueryCtx, "db">;
/**
* Get cart by userId or sessionId (read-only). Returns null if not found.
*/
export async function getCart(
ctx: CartReadCtx,
userId?: Id<"users">,
sessionId?: string
) {
if (userId) {
return await ctx.db
.query("carts")
.withIndex("by_user", (q) => q.eq("userId", userId))
.unique();
}
if (sessionId) {
return await ctx.db
.query("carts")
.withIndex("by_session", (q) => q.eq("sessionId", sessionId))
.unique();
}
return null;
}
/**
* Get existing cart or create a new one. Mutation-only; use from addItem, updateItem, removeItem, clear, merge.
* At least one of userId or sessionId must be provided.
*/
export async function getOrCreateCart(
ctx: MutationCtx,
userId?: Id<"users">,
sessionId?: string
): Promise<{ _id: Id<"carts">; items: { productId: Id<"products">; variantId?: Id<"productVariants">; quantity: number; price: number }[] }> {
if (!userId && !sessionId) {
throw new Error("Either userId or sessionId must be provided");
}
const now = Date.now();
const expiresAt = now + CART_EXPIRY_MS;
if (userId) {
const existing = await ctx.db
.query("carts")
.withIndex("by_user", (q) => q.eq("userId", userId))
.unique();
if (existing) return existing;
const id = await ctx.db.insert("carts", {
userId,
items: [],
createdAt: now,
updatedAt: now,
expiresAt,
});
const cart = await ctx.db.get(id);
if (!cart) throw new Error("Failed to create cart");
return cart;
}
const existing = await ctx.db
.query("carts")
.withIndex("by_session", (q) => q.eq("sessionId", sessionId!))
.unique();
if (existing) return existing;
const id = await ctx.db.insert("carts", {
sessionId: sessionId!,
items: [],
createdAt: now,
updatedAt: now,
expiresAt,
});
const cart = await ctx.db.get(id);
if (!cart) throw new Error("Failed to create cart");
return cart;
}

View File

@@ -0,0 +1,11 @@
import { QueryCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";
export async function getCategoryOrThrow(
ctx: QueryCtx,
id: Id<"categories">,
) {
const category = await ctx.db.get(id);
if (!category) throw new Error("Category not found");
return category;
}

View File

@@ -0,0 +1,127 @@
import { describe, it, expect, expectTypeOf } from "vitest";
import type {
ShippoConfidenceScore,
ShippoValidationValue,
ShippoAddressType,
ShippoValidationReason,
RecommendedAddress,
AddressValidationResult,
} from "./checkout";
describe("AddressValidationResult types", () => {
it("ShippoConfidenceScore accepts valid literals", () => {
expectTypeOf<"high">().toMatchTypeOf<ShippoConfidenceScore>();
expectTypeOf<"medium">().toMatchTypeOf<ShippoConfidenceScore>();
expectTypeOf<"low">().toMatchTypeOf<ShippoConfidenceScore>();
});
it("ShippoValidationValue accepts valid literals", () => {
expectTypeOf<"valid">().toMatchTypeOf<ShippoValidationValue>();
expectTypeOf<"partially_valid">().toMatchTypeOf<ShippoValidationValue>();
expectTypeOf<"invalid">().toMatchTypeOf<ShippoValidationValue>();
});
it("ShippoAddressType accepts valid literals", () => {
expectTypeOf<"residential">().toMatchTypeOf<ShippoAddressType>();
expectTypeOf<"commercial">().toMatchTypeOf<ShippoAddressType>();
expectTypeOf<"unknown">().toMatchTypeOf<ShippoAddressType>();
expectTypeOf<"po_box">().toMatchTypeOf<ShippoAddressType>();
expectTypeOf<"military">().toMatchTypeOf<ShippoAddressType>();
});
it("ShippoValidationReason has code and description", () => {
expectTypeOf<ShippoValidationReason>().toHaveProperty("code");
expectTypeOf<ShippoValidationReason>().toHaveProperty("description");
});
it("RecommendedAddress has required fields and no state", () => {
expectTypeOf<RecommendedAddress>().toHaveProperty("addressLine1");
expectTypeOf<RecommendedAddress>().toHaveProperty("city");
expectTypeOf<RecommendedAddress>().toHaveProperty("postalCode");
expectTypeOf<RecommendedAddress>().toHaveProperty("country");
expectTypeOf<RecommendedAddress>().toHaveProperty("confidenceScore");
expectTypeOf<RecommendedAddress>().toHaveProperty("confidenceCode");
expectTypeOf<RecommendedAddress>().toHaveProperty("confidenceDescription");
type Keys = keyof RecommendedAddress;
expectTypeOf<"state" extends Keys ? true : false>().toEqualTypeOf<false>();
expectTypeOf<"addressLine2" extends Keys ? true : false>().toEqualTypeOf<false>();
});
it("AddressValidationResult is structurally complete", () => {
expectTypeOf<AddressValidationResult>().toHaveProperty("isValid");
expectTypeOf<AddressValidationResult>().toHaveProperty("validationValue");
expectTypeOf<AddressValidationResult>().toHaveProperty("reasons");
expectTypeOf<AddressValidationResult>().toHaveProperty("addressType");
expectTypeOf<AddressValidationResult>().toHaveProperty("changedAttributes");
expectTypeOf<AddressValidationResult>().toHaveProperty("originalAddress");
});
it("recommendedAddress is optional on AddressValidationResult", () => {
const withoutRecommended: AddressValidationResult = {
isValid: true,
validationValue: "valid",
reasons: [],
addressType: "unknown",
changedAttributes: [],
originalAddress: {
addressLine1: "10 Downing Street",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
},
};
expect(withoutRecommended.recommendedAddress).toBeUndefined();
});
it("AddressValidationResult accepts a full object with recommended address", () => {
const full: AddressValidationResult = {
isValid: false,
validationValue: "partially_valid",
reasons: [{ code: "postal_data_match", description: "Postal code matched" }],
addressType: "unknown",
changedAttributes: ["postalCode"],
recommendedAddress: {
addressLine1: "10 Downing Street",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
completeAddress: "10 Downing Street;LONDON;SW1A 2AA;UNITED KINGDOM",
confidenceScore: "high",
confidenceCode: "postal_data_match",
confidenceDescription: "Matched via postal data",
},
originalAddress: {
addressLine1: "10 Downing St",
city: "London",
postalCode: "SW1A2AA",
country: "GB",
},
};
expect(full.isValid).toBe(false);
expect(full.recommendedAddress).toBeDefined();
expect(full.recommendedAddress!.confidenceScore).toBe("high");
});
it("originalAddress uses additionalInformation instead of addressLine2", () => {
const result: AddressValidationResult = {
isValid: true,
validationValue: "valid",
reasons: [],
addressType: "unknown",
changedAttributes: [],
originalAddress: {
addressLine1: "10 Downing Street",
additionalInformation: "Flat 1",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
},
};
expect(result.originalAddress.additionalInformation).toBe("Flat 1");
type OrigKeys = keyof AddressValidationResult["originalAddress"];
expectTypeOf<"state" extends OrigKeys ? true : false>().toEqualTypeOf<false>();
expectTypeOf<"addressLine2" extends OrigKeys ? true : false>().toEqualTypeOf<false>();
});
});

257
convex/model/checkout.ts Normal file
View File

@@ -0,0 +1,257 @@
import type { Id } from "../_generated/dataModel";
import type { QueryCtx } from "../_generated/server";
// ─── Cart Item Issues (discriminated union) ─────────────────────────────────
export type CartItemIssue =
| { type: "out_of_stock"; variantId: Id<"productVariants">; requested: number; available: number }
| { type: "insufficient_stock"; variantId: Id<"productVariants">; requested: number; available: number }
| { type: "variant_inactive"; variantId: Id<"productVariants"> }
| { type: "variant_not_found"; variantId: Id<"productVariants"> }
| { type: "product_not_found"; productId: Id<"products"> }
| { type: "price_changed"; variantId: Id<"productVariants">; oldPrice: number; newPrice: number };
// ─── Validated & Enriched Cart Item ─────────────────────────────────────────
export type ValidatedCartItem = {
variantId: Id<"productVariants">;
productId: Id<"products">;
quantity: number;
unitPrice: number;
originalPrice: number;
productName: string;
variantName: string;
sku: string;
imageUrl: string | undefined;
stockQuantity: number;
weight: number;
weightUnit: "g" | "kg" | "lb" | "oz";
length: number | undefined;
width: number | undefined;
height: number | undefined;
dimensionUnit: "cm" | "in" | undefined;
productSlug: string;
parentCategorySlug: string;
childCategorySlug: string;
};
// ─── Validation Result ──────────────────────────────────────────────────────
export type CartValidationResult = {
valid: boolean;
items: ValidatedCartItem[];
issues: CartItemIssue[];
subtotal: number;
};
// ─── Validation & Enrichment Helper ─────────────────────────────────────────
/**
* Validates every cart line item (stock, active status, price drift) and
* enriches each with weight/dimensions for downstream shipping-rate lookups.
*
* Price-change issues are warnings — they do NOT block checkout.
* Stock/missing/inactive issues ARE blocking.
*/
export async function validateAndEnrichCart(
ctx: Pick<QueryCtx, "db">,
items: { productId: Id<"products">; variantId?: Id<"productVariants">; quantity: number; price: number }[]
): Promise<CartValidationResult> {
const validatedItems: ValidatedCartItem[] = [];
const issues: CartItemIssue[] = [];
const blockingVariantIds = new Set<Id<"productVariants">>();
for (const item of items) {
if (!item.variantId) continue;
const variantId = item.variantId;
const variant = await ctx.db.get(variantId);
if (!variant) {
issues.push({ type: "variant_not_found" as const, variantId });
continue;
}
if (!variant.isActive) {
issues.push({ type: "variant_inactive" as const, variantId });
continue;
}
const product = await ctx.db.get(variant.productId);
if (!product || product.status !== "active") {
issues.push({ type: "product_not_found" as const, productId: item.productId });
continue;
}
if (variant.stockQuantity === 0) {
issues.push({
type: "out_of_stock" as const,
variantId,
requested: item.quantity,
available: 0,
});
blockingVariantIds.add(variantId);
} else if (variant.stockQuantity < item.quantity) {
issues.push({
type: "insufficient_stock" as const,
variantId,
requested: item.quantity,
available: variant.stockQuantity,
});
blockingVariantIds.add(variantId);
}
if (variant.price !== item.price) {
issues.push({
type: "price_changed" as const,
variantId,
oldPrice: item.price,
newPrice: variant.price,
});
}
const images = await ctx.db
.query("productImages")
.withIndex("by_product", (q) => q.eq("productId", variant.productId))
.collect();
images.sort((a, b) => a.position - b.position);
validatedItems.push({
variantId,
productId: variant.productId,
quantity: item.quantity,
unitPrice: variant.price,
originalPrice: item.price,
productName: product.name,
variantName: variant.name,
sku: variant.sku,
imageUrl: images[0]?.url,
stockQuantity: variant.stockQuantity,
weight: variant.weight,
weightUnit: variant.weightUnit,
length: variant.length,
width: variant.width,
height: variant.height,
dimensionUnit: variant.dimensionUnit,
productSlug: product.slug,
parentCategorySlug: product.parentCategorySlug,
childCategorySlug: product.childCategorySlug,
});
}
const blockingIssues = issues.filter(i => i.type !== "price_changed");
const valid = blockingIssues.length === 0;
const subtotal = validatedItems
.filter(item => !blockingVariantIds.has(item.variantId))
.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
return { valid, items: validatedItems, issues, subtotal };
}
// ─── Shippo Shipping Rate Types ──────────────────────────────────────────────
export type ShippoRate = {
objectId: string;
provider: string;
servicelevelName: string;
servicelevelToken: string;
amount: string;
currency: string;
estimatedDays: number | null;
durationTerms: string;
arrivesBy: string | null;
carrierAccount: string;
};
export type SelectedShippingRate = {
provider: string;
serviceName: string;
serviceToken: string;
amount: number;
currency: string;
estimatedDays: number | null;
durationTerms: string;
carrierAccount: string;
};
export type ShippingRateResult = {
shipmentObjectId: string;
selectedRate: SelectedShippingRate;
alternativeRates: SelectedShippingRate[];
cartSubtotal: number;
shippingTotal: number;
orderTotal: number;
};
// ─── Checkout Session Types (Stripe) ─────────────────────────────────────────
export type CreateCheckoutSessionInput = {
addressId: Id<"addresses">;
shipmentObjectId: string;
shippingRate: {
provider: string;
serviceName: string;
serviceToken: string;
amount: number;
currency: string;
estimatedDays: number | null;
durationTerms: string;
carrierAccount: string;
};
sessionId: string | undefined;
};
export type CheckoutSessionResult = {
clientSecret: string;
};
export type CheckoutSessionStatus = {
status: "complete" | "expired" | "open";
paymentStatus: string;
customerEmail: string | null;
};
// ─── Shippo Address Validation Types ─────────────────────────────────────────
export type ShippoConfidenceScore = "high" | "medium" | "low";
export type ShippoValidationValue = "valid" | "partially_valid" | "invalid";
export type ShippoAddressType =
| "residential"
| "commercial"
| "unknown"
| "po_box"
| "military";
export type ShippoValidationReason = {
code: string;
description: string;
};
export type RecommendedAddress = {
addressLine1: string;
additionalInformation?: string;
city: string;
postalCode: string;
country: string;
completeAddress?: string;
confidenceScore: ShippoConfidenceScore;
confidenceCode: string;
confidenceDescription: string;
};
export type AddressValidationResult = {
isValid: boolean;
validationValue: ShippoValidationValue;
reasons: ShippoValidationReason[];
addressType: ShippoAddressType;
changedAttributes: string[];
recommendedAddress?: RecommendedAddress;
originalAddress: {
addressLine1: string;
additionalInformation?: string;
city: string;
postalCode: string;
country: string;
};
};

85
convex/model/orders.ts Normal file
View File

@@ -0,0 +1,85 @@
import { QueryCtx } from "../_generated/server";
import { Id, Doc } from "../_generated/dataModel";
export async function getOrderWithItems(
ctx: QueryCtx,
orderId: Id<"orders">,
) {
const order = await ctx.db.get(orderId);
if (!order) return null;
const items = await ctx.db
.query("orderItems")
.withIndex("by_order", (q) => q.eq("orderId", orderId))
.collect();
return { ...order, items };
}
/**
* Determines whether a customer is allowed to cancel a given order.
*
* NOTE: Cancellation only updates order status and restores stock.
* Stripe refund processing is a separate concern handled via the admin
* dashboard or a future automated flow. This helper does NOT trigger a refund.
*/
export function canCustomerCancel(order: Doc<"orders">): {
allowed: boolean;
reason?: string;
} {
switch (order.status) {
case "confirmed":
return { allowed: true };
case "pending":
return {
allowed: false,
reason: "Order is still awaiting payment confirmation.",
};
case "cancelled":
return { allowed: false, reason: "Order is already cancelled." };
case "refunded":
return { allowed: false, reason: "Order has already been refunded." };
default:
return {
allowed: false,
reason:
"Order has progressed past the cancellation window. Please contact support.",
};
}
}
export interface OutOfStockItem {
variantId: Id<"productVariants">;
requested: number;
available: number;
}
/**
* Check each cart item for sufficient stock. Returns list of out-of-stock entries.
*/
export async function validateCartItems(
ctx: Pick<QueryCtx, "db">,
items: { variantId?: Id<"productVariants">; quantity: number }[]
): Promise<OutOfStockItem[]> {
const outOfStock: OutOfStockItem[] = [];
for (const item of items) {
if (!item.variantId) continue;
const variant = await ctx.db.get(item.variantId);
if (!variant) {
outOfStock.push({
variantId: item.variantId,
requested: item.quantity,
available: 0,
});
continue;
}
if (variant.stockQuantity < item.quantity) {
outOfStock.push({
variantId: item.variantId,
requested: item.quantity,
available: variant.stockQuantity,
});
}
}
return outOfStock;
}

80
convex/model/products.ts Normal file
View File

@@ -0,0 +1,80 @@
import { QueryCtx, MutationCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";
/**
* Recalculate product averageRating and reviewCount from approved reviews.
* Call after review approve/delete.
*/
export async function recalculateProductRating(
ctx: MutationCtx,
productId: Id<"products">,
) {
const approved = await ctx.db
.query("reviews")
.withIndex("by_product_approved", (q) =>
q.eq("productId", productId).eq("isApproved", true),
)
.collect();
const count = approved.length;
const averageRating =
count > 0
? approved.reduce((sum, r) => sum + r.rating, 0) / count
: undefined;
const reviewCount = count > 0 ? count : undefined;
await ctx.db.patch(productId, {
averageRating,
reviewCount,
updatedAt: Date.now(),
});
}
export async function getProductWithRelations(
ctx: QueryCtx,
productId: Id<"products">,
) {
const product = await ctx.db.get(productId);
if (!product) return null;
const [imagesRaw, variants, category] = await Promise.all([
ctx.db
.query("productImages")
.withIndex("by_product", (q) => q.eq("productId", productId))
.collect(),
ctx.db
.query("productVariants")
.withIndex("by_product_and_active", (q) =>
q.eq("productId", productId).eq("isActive", true),
)
.collect(),
ctx.db.get(product.categoryId),
]);
const images = imagesRaw.sort((a, b) => a.position - b.position);
return { ...product, images, variants, category };
}
export async function enrichProducts(
ctx: QueryCtx,
products: Awaited<ReturnType<typeof ctx.db.query>>[],
) {
return Promise.all(
products.map(async (product: any) => {
const [imagesRaw, variants] = await Promise.all([
ctx.db
.query("productImages")
.withIndex("by_product", (q) => q.eq("productId", product._id))
.collect(),
ctx.db
.query("productVariants")
.withIndex("by_product_and_active", (q) =>
q.eq("productId", product._id).eq("isActive", true),
)
.collect(),
]);
const images = imagesRaw.sort((a, b) => a.position - b.position);
return { ...product, images, variants };
}),
);
}

775
convex/model/shippo.test.ts Normal file
View File

@@ -0,0 +1,775 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
validateAddressWithShippo,
PREFERRED_CARRIERS,
computeParcel,
getShippingRatesFromShippo,
selectBestRate,
} from "./shippo";
import type { ValidatedCartItem } from "./checkout";
import type { ShippoRate } from "./checkout";
import type { Id } from "../_generated/dataModel";
const validInput = {
addressLine1: "10 Downing Street",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
};
const shippoValidResponse = {
original_address: {
address_line_1: "10 Downing Street",
address_line_2: undefined,
city_locality: "London",
state_province: "Westminster",
postal_code: "SW1A 2AA",
country_code: "GB",
},
analysis: {
validation_result: {
value: "valid",
reasons: [],
},
address_type: "unknown",
changed_attributes: [],
},
};
const shippoPartiallyValidResponse = {
original_address: {
address_line_1: "10 Downing St",
city_locality: "London",
state_province: "Westminster",
postal_code: "SW1A 2AA",
country_code: "GB",
},
recommended_address: {
address_line_1: "10 Downing Street",
address_line_2: "Flat 1",
city_locality: "London",
state_province: "Westminster",
postal_code: "SW1A 2AA",
country_code: "GB",
complete_address: "10 Downing Street;Flat 1;LONDON;SW1A 2AA;UNITED KINGDOM",
confidence_result: {
score: "high",
code: "postal_data_match",
description: "Matched via postal data",
},
},
analysis: {
validation_result: {
value: "partially_valid",
reasons: [{ code: "street_suffix", description: "Street suffix corrected" }],
},
address_type: "unknown",
changed_attributes: ["address_line_1", "address_line_2"],
},
};
const shippoInvalidResponse = {
original_address: {
address_line_1: "999 Nowhere Lane",
city_locality: "Faketown",
state_province: "",
postal_code: "ZZ99 9ZZ",
country_code: "GB",
},
analysis: {
validation_result: {
value: "invalid",
reasons: [
{ code: "address_not_found", description: "Address could not be found" },
{ code: "invalid_postal_code", description: "Postal code is not valid" },
],
},
address_type: "unknown",
},
};
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
vi.stubEnv("SHIPPO_API_KEY", "shippo_test_key_123");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
function mockFetchOk(body: unknown) {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(body),
});
}
describe("validateAddressWithShippo", () => {
// ── Request construction ────────────────────────────────────────────
it("builds correct URL with mapped query params (no state_province)", async () => {
mockFetchOk(shippoValidResponse);
await validateAddressWithShippo({
addressLine1: "10 Downing Street",
additionalInformation: "Flat 1",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
name: "Alice Smith",
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const url = new URL(fetchSpy.mock.calls[0][0]);
expect(url.origin + url.pathname).toBe(
"https://api.goshippo.com/v2/addresses/validate",
);
expect(url.searchParams.get("address_line_1")).toBe("10 Downing Street");
expect(url.searchParams.get("address_line_2")).toBe("Flat 1");
expect(url.searchParams.get("city_locality")).toBe("London");
expect(url.searchParams.get("postal_code")).toBe("SW1A 2AA");
expect(url.searchParams.get("country_code")).toBe("GB");
expect(url.searchParams.get("name")).toBe("Alice Smith");
expect(url.searchParams.has("state_province")).toBe(false);
});
it("omits optional params when not provided", async () => {
mockFetchOk(shippoValidResponse);
await validateAddressWithShippo(validInput);
const url = new URL(fetchSpy.mock.calls[0][0]);
expect(url.searchParams.has("address_line_2")).toBe(false);
expect(url.searchParams.has("name")).toBe(false);
expect(url.searchParams.has("state_province")).toBe(false);
});
it("sends ShippoToken authorization header", async () => {
mockFetchOk(shippoValidResponse);
await validateAddressWithShippo(validInput);
const opts = fetchSpy.mock.calls[0][1];
expect(opts.headers.Authorization).toBe("ShippoToken shippo_test_key_123");
expect(opts.method).toBe("GET");
});
// ── Valid address (no corrections) ──────────────────────────────────
it("returns isValid: true for a fully valid address", async () => {
mockFetchOk(shippoValidResponse);
const result = await validateAddressWithShippo(validInput);
expect(result.isValid).toBe(true);
expect(result.validationValue).toBe("valid");
expect(result.reasons).toEqual([]);
expect(result.addressType).toBe("unknown");
expect(result.changedAttributes).toEqual([]);
expect(result.recommendedAddress).toBeUndefined();
});
it("maps originalAddress without state field", async () => {
mockFetchOk(shippoValidResponse);
const result = await validateAddressWithShippo(validInput);
expect(result.originalAddress).toEqual({
addressLine1: "10 Downing Street",
additionalInformation: undefined,
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
});
expect(result.originalAddress).not.toHaveProperty("state");
});
// ── Partially valid address (with recommended) ─────────────────────
it("returns partially_valid result with recommendedAddress mapped", async () => {
mockFetchOk(shippoPartiallyValidResponse);
const result = await validateAddressWithShippo(validInput);
expect(result.isValid).toBe(false);
expect(result.validationValue).toBe("partially_valid");
expect(result.addressType).toBe("unknown");
expect(result.changedAttributes).toEqual(["address_line_1", "address_line_2"]);
expect(result.reasons).toEqual([
{ code: "street_suffix", description: "Street suffix corrected" },
]);
});
it("maps recommended address with additionalInformation (no state)", async () => {
mockFetchOk(shippoPartiallyValidResponse);
const result = await validateAddressWithShippo(validInput);
const rec = result.recommendedAddress;
expect(rec).toBeDefined();
expect(rec!.addressLine1).toBe("10 Downing Street");
expect(rec!.additionalInformation).toBe("Flat 1");
expect(rec!.city).toBe("London");
expect(rec!.postalCode).toBe("SW1A 2AA");
expect(rec!.country).toBe("GB");
expect(rec!.completeAddress).toBe(
"10 Downing Street;Flat 1;LONDON;SW1A 2AA;UNITED KINGDOM",
);
expect(rec!.confidenceScore).toBe("high");
expect(rec!.confidenceCode).toBe("postal_data_match");
expect(rec!.confidenceDescription).toBe("Matched via postal data");
expect(rec).not.toHaveProperty("state");
});
// ── Invalid address ────────────────────────────────────────────────
it("returns isValid: false with reasons for an invalid address", async () => {
mockFetchOk(shippoInvalidResponse);
const result = await validateAddressWithShippo({
addressLine1: "999 Nowhere Lane",
city: "Faketown",
postalCode: "ZZ99 9ZZ",
country: "GB",
});
expect(result.isValid).toBe(false);
expect(result.validationValue).toBe("invalid");
expect(result.addressType).toBe("unknown");
expect(result.recommendedAddress).toBeUndefined();
expect(result.reasons).toHaveLength(2);
expect(result.reasons[0].code).toBe("address_not_found");
expect(result.reasons[1].code).toBe("invalid_postal_code");
});
it("defaults changedAttributes to [] when missing from response", async () => {
mockFetchOk(shippoInvalidResponse);
const result = await validateAddressWithShippo({
addressLine1: "999 Nowhere Lane",
city: "Faketown",
postalCode: "ZZ99 9ZZ",
country: "GB",
});
expect(result.changedAttributes).toEqual([]);
});
// ── Error handling ─────────────────────────────────────────────────
it("throws when SHIPPO_API_KEY is missing", async () => {
vi.stubEnv("SHIPPO_API_KEY", "");
await expect(validateAddressWithShippo(validInput)).rejects.toThrow(
/missing API key/i,
);
});
it("throws when fetch rejects (network error)", async () => {
fetchSpy.mockRejectedValue(new TypeError("Failed to fetch"));
await expect(validateAddressWithShippo(validInput)).rejects.toThrow(
/unreachable/i,
);
});
it("throws when Shippo returns non-200 status", async () => {
fetchSpy.mockResolvedValue({
ok: false,
status: 503,
json: () => Promise.resolve({}),
});
await expect(validateAddressWithShippo(validInput)).rejects.toThrow(
/unavailable.*503/i,
);
});
it("throws when response body is not valid JSON", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError("Unexpected token")),
});
await expect(validateAddressWithShippo(validInput)).rejects.toThrow(
/unexpected response/i,
);
});
it("throws when response is missing analysis.validation_result", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ original_address: {}, analysis: {} }),
});
await expect(validateAddressWithShippo(validInput)).rejects.toThrow(
/malformed/i,
);
});
});
// ─── Test helpers ───────────────────────────────────────────────────────────
function makeCartItem(overrides: Partial<ValidatedCartItem> = {}): ValidatedCartItem {
return {
variantId: "variant1" as Id<"productVariants">,
productId: "product1" as Id<"products">,
quantity: 1,
unitPrice: 1000,
originalPrice: 1000,
productName: "Test Product",
variantName: "Default",
sku: "TST-001",
imageUrl: undefined,
stockQuantity: 10,
weight: 500,
weightUnit: "g",
length: undefined,
width: undefined,
height: undefined,
dimensionUnit: undefined,
productSlug: "test-product",
parentCategorySlug: "parent",
childCategorySlug: "child",
...overrides,
};
}
function makeShippoRate(overrides: Partial<ShippoRate> = {}): ShippoRate {
return {
objectId: "rate_abc123",
provider: "DPD UK",
servicelevelName: "Next Day",
servicelevelToken: "dpd_uk_next_day",
amount: "5.50",
currency: "GBP",
estimatedDays: 1,
durationTerms: "1-2 business days",
arrivesBy: null,
carrierAccount: "ca_abc123",
...overrides,
};
}
// ─── PREFERRED_CARRIERS ─────────────────────────────────────────────────────
describe("PREFERRED_CARRIERS", () => {
it("contains the four expected carriers", () => {
expect(PREFERRED_CARRIERS).toEqual(["DPD UK", "Evri UK", "UPS", "UDS"]);
});
});
// ─── computeParcel ──────────────────────────────────────────────────────────
describe("computeParcel", () => {
// ── Weight normalization ────────────────────────────────────────────
it("sums weights in grams for items with weightUnit 'g'", () => {
const items = [
makeCartItem({ weight: 200, weightUnit: "g", quantity: 2 }),
makeCartItem({ weight: 300, weightUnit: "g", quantity: 1 }),
];
const result = computeParcel(items);
expect(result.weight).toBe("700");
expect(result.mass_unit).toBe("g");
});
it("converts kg to grams", () => {
const items = [makeCartItem({ weight: 1.5, weightUnit: "kg", quantity: 1 })];
const result = computeParcel(items);
expect(result.weight).toBe("1500");
});
it("converts lb to grams", () => {
const items = [makeCartItem({ weight: 1, weightUnit: "lb", quantity: 1 })];
const result = computeParcel(items);
expect(result.weight).toBe("454");
});
it("converts oz to grams", () => {
const items = [makeCartItem({ weight: 1, weightUnit: "oz", quantity: 1 })];
const result = computeParcel(items);
expect(result.weight).toBe("28");
});
it("multiplies weight by quantity", () => {
const items = [makeCartItem({ weight: 100, weightUnit: "g", quantity: 5 })];
const result = computeParcel(items);
expect(result.weight).toBe("500");
});
it("handles mixed weight units across items", () => {
const items = [
makeCartItem({ weight: 500, weightUnit: "g", quantity: 1 }),
makeCartItem({ weight: 1, weightUnit: "kg", quantity: 2 }),
];
const result = computeParcel(items);
// 500g + (1kg * 2) = 500 + 2000 = 2500g
expect(result.weight).toBe("2500");
});
// ── No dimensions ──────────────────────────────────────────────────
it("omits dimension fields when no items have dimensions", () => {
const items = [makeCartItem({ length: undefined, width: undefined, height: undefined, dimensionUnit: undefined })];
const result = computeParcel(items);
expect(result).not.toHaveProperty("length");
expect(result).not.toHaveProperty("width");
expect(result).not.toHaveProperty("height");
expect(result).not.toHaveProperty("distance_unit");
});
it("omits dimensions when only some dimension fields present", () => {
const items = [makeCartItem({ length: 10, width: undefined, height: 5, dimensionUnit: "cm" })];
const result = computeParcel(items);
expect(result).not.toHaveProperty("length");
});
// ── With dimensions ────────────────────────────────────────────────
it("computes dimensions in cm: max length, max width, sum height", () => {
const items = [
makeCartItem({ length: 30, width: 20, height: 5, dimensionUnit: "cm", quantity: 2 }),
makeCartItem({ length: 25, width: 25, height: 3, dimensionUnit: "cm", quantity: 1 }),
];
const result = computeParcel(items);
expect(result.length).toBe("30");
expect(result.width).toBe("25");
// height: (5*2) + (3*1) = 13
expect(result.height).toBe("13");
expect(result.distance_unit).toBe("cm");
});
it("converts inches to cm for dimensions", () => {
const items = [
makeCartItem({ length: 10, width: 8, height: 4, dimensionUnit: "in", quantity: 1 }),
];
const result = computeParcel(items);
expect(result.length).toBe("25.4");
expect(result.width).toBe("20.32");
expect(result.height).toBe("10.16");
expect(result.distance_unit).toBe("cm");
});
it("ignores items without full dimensions when computing parcel dimensions", () => {
const withDims = makeCartItem({ length: 20, width: 15, height: 10, dimensionUnit: "cm", quantity: 1, weight: 200, weightUnit: "g" });
const withoutDims = makeCartItem({ length: undefined, width: undefined, height: undefined, dimensionUnit: undefined, quantity: 1, weight: 300, weightUnit: "g" });
const result = computeParcel([withDims, withoutDims]);
expect(result.length).toBe("20");
expect(result.width).toBe("15");
expect(result.height).toBe("10");
// weight still sums both items
expect(result.weight).toBe("500");
});
it("handles mixed dimension units across items", () => {
const items = [
makeCartItem({ length: 10, width: 10, height: 5, dimensionUnit: "in", quantity: 1 }),
makeCartItem({ length: 30, width: 20, height: 10, dimensionUnit: "cm", quantity: 1 }),
];
const result = computeParcel(items);
// max length: max(25.4, 30) = 30
expect(result.length).toBe("30");
// max width: max(25.4, 20) = 25.4
expect(result.width).toBe("25.4");
// total height: 12.7 + 10 = 22.7
expect(result.height).toBe("22.7");
});
it("handles a single item with quantity > 1 stacking height", () => {
const items = [makeCartItem({ length: 20, width: 15, height: 3, dimensionUnit: "cm", quantity: 4 })];
const result = computeParcel(items);
expect(result.height).toBe("12");
});
});
// ─── getShippingRatesFromShippo ─────────────────────────────────────────────
describe("getShippingRatesFromShippo", () => {
const validShipmentsInput = {
sourceAddressId: "addr_source_123",
destinationAddress: {
name: "John Doe",
street1: "10 Downing Street",
city: "London",
zip: "SW1A 2AA",
country: "GB",
},
parcels: [{ weight: "500", mass_unit: "g" }],
};
const shippoShipmentsResponse = {
object_id: "shp_abc123",
rates: [
{
object_id: "rate_001",
provider: "DPD UK",
servicelevel: { name: "Next Day", token: "dpd_uk_next_day" },
amount: "5.50",
currency: "GBP",
estimated_days: 1,
duration_terms: "1-2 business days",
arrives_by: null,
carrier_account: "ca_dpd_001",
},
{
object_id: "rate_002",
provider: "UPS",
servicelevel: { name: "Standard", token: "ups_standard" },
amount: "7.99",
currency: "GBP",
estimated_days: 3,
duration_terms: "3-5 business days",
carrier_account: "ca_ups_001",
},
],
};
it("sends correct POST request to Shippo /shipments/ endpoint", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(shippoShipmentsResponse),
});
await getShippingRatesFromShippo(validShipmentsInput);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, opts] = fetchSpy.mock.calls[0];
expect(url).toBe("https://api.goshippo.com/shipments/");
expect(opts.method).toBe("POST");
expect(opts.headers.Authorization).toBe("ShippoToken shippo_test_key_123");
expect(opts.headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.address_from).toBe("addr_source_123");
expect(body.address_to.name).toBe("John Doe");
expect(body.address_to.street1).toBe("10 Downing Street");
expect(body.parcels).toHaveLength(1);
expect(body.async).toBe(false);
});
it("returns shipmentObjectId and mapped rates", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(shippoShipmentsResponse),
});
const result = await getShippingRatesFromShippo(validShipmentsInput);
expect(result.shipmentObjectId).toBe("shp_abc123");
expect(result.rates).toHaveLength(2);
const rate1 = result.rates[0];
expect(rate1.objectId).toBe("rate_001");
expect(rate1.provider).toBe("DPD UK");
expect(rate1.servicelevelName).toBe("Next Day");
expect(rate1.servicelevelToken).toBe("dpd_uk_next_day");
expect(rate1.amount).toBe("5.50");
expect(rate1.currency).toBe("GBP");
expect(rate1.estimatedDays).toBe(1);
expect(rate1.durationTerms).toBe("1-2 business days");
expect(rate1.arrivesBy).toBeNull();
expect(rate1.carrierAccount).toBe("ca_dpd_001");
});
it("maps arrives_by to null when absent from response", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(shippoShipmentsResponse),
});
const result = await getShippingRatesFromShippo(validShipmentsInput);
expect(result.rates[1].arrivesBy).toBeNull();
});
it("maps arrives_by when present in response", async () => {
const responseWithArrival = {
object_id: "shp_abc123",
rates: [
{
...shippoShipmentsResponse.rates[0],
arrives_by: "2025-03-05T18:00:00Z",
},
],
};
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(responseWithArrival),
});
const result = await getShippingRatesFromShippo(validShipmentsInput);
expect(result.rates[0].arrivesBy).toBe("2025-03-05T18:00:00Z");
});
it("throws when SHIPPO_API_KEY is missing", async () => {
vi.stubEnv("SHIPPO_API_KEY", "");
await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow(
/missing API key/i,
);
});
it("throws when fetch rejects (network error)", async () => {
fetchSpy.mockRejectedValue(new TypeError("Failed to fetch"));
await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow(
/unreachable/i,
);
});
it("throws when Shippo returns non-200 status", async () => {
fetchSpy.mockResolvedValue({
ok: false,
status: 422,
json: () => Promise.resolve({}),
});
await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow(
/unavailable.*422/i,
);
});
it("throws when response body is not valid JSON", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError("Unexpected token")),
});
await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow(
/unexpected response/i,
);
});
it("returns empty rates array when Shippo returns no rates", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ object_id: "shp_empty", rates: [] }),
});
const result = await getShippingRatesFromShippo(validShipmentsInput);
expect(result.shipmentObjectId).toBe("shp_empty");
expect(result.rates).toEqual([]);
});
});
// ─── selectBestRate ─────────────────────────────────────────────────────────
describe("selectBestRate", () => {
it("throws when rates array is empty", () => {
expect(() => selectBestRate([])).toThrow(
/no shipping rates available/i,
);
});
it("selects preferred carrier rate with fewest transit days", () => {
const rates = [
makeShippoRate({ provider: "DPD UK", estimatedDays: 2, amount: "4.00" }),
makeShippoRate({ provider: "Evri UK", estimatedDays: 1, amount: "6.00" }),
makeShippoRate({ provider: "Royal Mail", estimatedDays: 1, amount: "3.00" }),
];
const { selected } = selectBestRate(rates);
expect(selected.provider).toBe("Evri UK");
expect(selected.estimatedDays).toBe(1);
});
it("breaks ties by cheapest amount among same transit days", () => {
const rates = [
makeShippoRate({ provider: "UPS", estimatedDays: 2, amount: "8.00" }),
makeShippoRate({ provider: "DPD UK", estimatedDays: 2, amount: "5.50" }),
makeShippoRate({ provider: "Evri UK", estimatedDays: 2, amount: "6.00" }),
];
const { selected } = selectBestRate(rates);
expect(selected.provider).toBe("DPD UK");
expect(selected.amount).toBe("5.50");
});
it("returns up to 2 alternatives from preferred carriers", () => {
const rates = [
makeShippoRate({ provider: "DPD UK", estimatedDays: 1, amount: "5.50" }),
makeShippoRate({ provider: "UPS", estimatedDays: 2, amount: "7.00" }),
makeShippoRate({ provider: "Evri UK", estimatedDays: 3, amount: "4.00" }),
makeShippoRate({ provider: "UDS", estimatedDays: 4, amount: "3.50" }),
];
const { selected, alternatives } = selectBestRate(rates);
expect(selected.provider).toBe("DPD UK");
expect(alternatives).toHaveLength(2);
expect(alternatives[0].provider).toBe("UPS");
expect(alternatives[1].provider).toBe("Evri UK");
});
it("uses case-insensitive matching for carrier names", () => {
const rates = [
makeShippoRate({ provider: "dpd uk", estimatedDays: 1, amount: "5.50" }),
makeShippoRate({ provider: "EVRI UK", estimatedDays: 2, amount: "4.00" }),
];
const { selected } = selectBestRate(rates);
expect(selected.provider).toBe("dpd uk");
});
it("falls back to all carriers when no preferred carriers present", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const rates = [
makeShippoRate({ provider: "Royal Mail", estimatedDays: 3, amount: "3.00" }),
makeShippoRate({ provider: "Parcelforce", estimatedDays: 1, amount: "9.00" }),
];
const { selected } = selectBestRate(rates);
expect(selected.provider).toBe("Parcelforce");
expect(selected.estimatedDays).toBe(1);
expect(warnSpy).toHaveBeenCalledWith(
"No preferred carriers returned rates. Falling back to all carriers.",
);
warnSpy.mockRestore();
});
it("fallback sorts all carriers by days then price", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const rates = [
makeShippoRate({ provider: "Royal Mail", estimatedDays: 2, amount: "7.00" }),
makeShippoRate({ provider: "Parcelforce", estimatedDays: 2, amount: "5.00" }),
makeShippoRate({ provider: "Hermes", estimatedDays: 3, amount: "3.00" }),
];
const { selected, alternatives } = selectBestRate(rates);
expect(selected.provider).toBe("Parcelforce");
expect(selected.amount).toBe("5.00");
expect(alternatives).toHaveLength(2);
expect(alternatives[0].provider).toBe("Royal Mail");
expect(alternatives[1].provider).toBe("Hermes");
warnSpy.mockRestore();
});
it("returns single preferred rate with empty alternatives", () => {
const rates = [
makeShippoRate({ provider: "DPD UK", estimatedDays: 1, amount: "5.50" }),
makeShippoRate({ provider: "Royal Mail", estimatedDays: 2, amount: "3.00" }),
];
const { selected, alternatives } = selectBestRate(rates);
expect(selected.provider).toBe("DPD UK");
expect(alternatives).toHaveLength(0);
});
it("filters out non-preferred carriers from selection when preferred exist", () => {
const rates = [
makeShippoRate({ provider: "Royal Mail", estimatedDays: 1, amount: "2.00" }),
makeShippoRate({ provider: "DPD UK", estimatedDays: 2, amount: "5.50" }),
];
const { selected } = selectBestRate(rates);
// DPD UK is preferred, even though Royal Mail is faster and cheaper
expect(selected.provider).toBe("DPD UK");
});
it("handles single rate in the array", () => {
const rates = [makeShippoRate({ provider: "UPS", estimatedDays: 3, amount: "10.00" })];
const { selected, alternatives } = selectBestRate(rates);
expect(selected.provider).toBe("UPS");
expect(alternatives).toHaveLength(0);
});
});

380
convex/model/shippo.ts Normal file
View File

@@ -0,0 +1,380 @@
import { ConvexError } from "convex/values";
import type {
AddressValidationResult,
RecommendedAddress,
ShippoRate,
ValidatedCartItem,
} from "./checkout";
type ValidateAddressInput = {
addressLine1: string;
additionalInformation?: string;
city: string;
postalCode: string;
country: string;
name?: string;
};
type ShippoRawResponse = {
original_address: {
address_line_1: string;
address_line_2?: string;
city_locality: string;
state_province: string;
postal_code: string;
country_code: string;
name?: string;
organization?: string;
};
recommended_address?: {
address_line_1: string;
address_line_2?: string;
city_locality: string;
state_province: string;
postal_code: string;
country_code: string;
complete_address?: string;
confidence_result: {
score: "high" | "medium" | "low";
code: string;
description: string;
};
};
analysis: {
validation_result: {
value: "valid" | "partially_valid" | "invalid";
reasons: Array<{ code: string; description: string }>;
};
address_type:
| "residential"
| "commercial"
| "unknown"
| "po_box"
| "military";
changed_attributes?: string[];
};
geo?: {
latitude: number;
longitude: number;
};
};
const SHIPPO_VALIDATE_URL =
"https://api.goshippo.com/v2/addresses/validate";
/**
* Calls Shippo Address Validation v2 and normalizes the response into
* an `AddressValidationResult`. This is a pure async helper — it does NOT
* export a Convex function; it's consumed by actions in `checkoutActions.ts`.
*/
export async function validateAddressWithShippo(
input: ValidateAddressInput,
): Promise<AddressValidationResult> {
const apiKey = process.env.SHIPPO_API_KEY;
if (!apiKey) {
throw new ConvexError(
"Address validation is unavailable (missing API key configuration).",
);
}
const params = new URLSearchParams();
params.set("address_line_1", input.addressLine1);
if (input.additionalInformation)
params.set("address_line_2", input.additionalInformation);
params.set("city_locality", input.city);
params.set("postal_code", input.postalCode);
params.set("country_code", input.country);
if (input.name) params.set("name", input.name);
let response: Response;
try {
response = await fetch(`${SHIPPO_VALIDATE_URL}?${params.toString()}`, {
method: "GET",
headers: { Authorization: `ShippoToken ${apiKey}` },
});
} catch (err) {
throw new ConvexError(
"Address validation service is unreachable. Please try again later.",
);
}
if (!response.ok) {
throw new ConvexError(
`Address validation service unavailable (status ${response.status}).`,
);
}
let body: ShippoRawResponse;
try {
body = (await response.json()) as ShippoRawResponse;
} catch {
throw new ConvexError(
"Address validation returned an unexpected response. Please try again.",
);
}
if (!body.analysis?.validation_result) {
throw new ConvexError(
"Address validation returned a malformed response.",
);
}
const { analysis, recommended_address, original_address } = body;
let recommendedAddress: RecommendedAddress | undefined;
if (recommended_address) {
recommendedAddress = {
addressLine1: recommended_address.address_line_1,
additionalInformation: recommended_address.address_line_2,
city: recommended_address.city_locality,
postalCode: recommended_address.postal_code,
country: recommended_address.country_code,
completeAddress: recommended_address.complete_address,
confidenceScore: recommended_address.confidence_result.score,
confidenceCode: recommended_address.confidence_result.code,
confidenceDescription:
recommended_address.confidence_result.description,
};
}
return {
isValid: analysis.validation_result.value === "valid",
validationValue: analysis.validation_result.value,
reasons: analysis.validation_result.reasons.map((r) => ({
code: r.code,
description: r.description,
})),
addressType: analysis.address_type,
changedAttributes: analysis.changed_attributes ?? [],
recommendedAddress,
originalAddress: {
addressLine1: original_address.address_line_1,
additionalInformation: original_address.address_line_2,
city: original_address.city_locality,
postalCode: original_address.postal_code,
country: original_address.country_code,
},
};
}
// ─── Shipping Rates Helpers ──────────────────────────────────────────────────
export const PREFERRED_CARRIERS = ["DPD UK", "Evri UK", "UPS", "UDS"];
/**
* Hard ceiling across preferred UK carriers.
* DPD UK premium (door-to-door, Saturday/Sunday): 30kg
* DPD UK standard (Classic, Next Day, Two Day): 20kg
* Evri UK (Courier Collection, ParcelShop): 15kg
*/
export const MAX_PARCEL_WEIGHT_G = 30_000;
const WEIGHT_TO_GRAMS: Record<ValidatedCartItem["weightUnit"], number> = {
g: 1,
kg: 1000,
lb: 453.592,
oz: 28.3495,
};
const DIMENSION_TO_CM: Record<NonNullable<ValidatedCartItem["dimensionUnit"]>, number> = {
cm: 1,
in: 2.54,
};
type ParcelResult = {
weight: string;
mass_unit: "g";
length?: string;
width?: string;
height?: string;
distance_unit?: "cm";
};
export function computeParcel(items: ValidatedCartItem[]): ParcelResult {
let totalWeightGrams = 0;
for (const item of items) {
const factor = WEIGHT_TO_GRAMS[item.weightUnit];
totalWeightGrams += item.weight * factor * item.quantity;
}
const withDimensions = items.filter(
(item): item is ValidatedCartItem & { length: number; width: number; height: number; dimensionUnit: "cm" | "in" } =>
item.length != null && item.width != null && item.height != null && item.dimensionUnit != null,
);
if (withDimensions.length === 0) {
return { weight: String(Math.round(totalWeightGrams)), mass_unit: "g" };
}
let maxLengthCm = 0;
let maxWidthCm = 0;
let totalHeightCm = 0;
for (const item of withDimensions) {
const factor = DIMENSION_TO_CM[item.dimensionUnit];
const lengthCm = item.length * factor;
const widthCm = item.width * factor;
const heightCm = item.height * factor * item.quantity;
if (lengthCm > maxLengthCm) maxLengthCm = lengthCm;
if (widthCm > maxWidthCm) maxWidthCm = widthCm;
totalHeightCm += heightCm;
}
return {
weight: String(Math.round(totalWeightGrams)),
mass_unit: "g",
length: String(Math.round(maxLengthCm * 100) / 100),
width: String(Math.round(maxWidthCm * 100) / 100),
height: String(Math.round(totalHeightCm * 100) / 100),
distance_unit: "cm",
};
}
const SHIPPO_SHIPMENTS_URL = "https://api.goshippo.com/shipments/";
export async function getShippingRatesFromShippo(input: {
sourceAddressId: string;
destinationAddress: {
name: string;
street1: string;
street2?: string;
city: string;
zip: string;
country: string;
phone?: string;
};
parcels: Array<{
weight: string;
mass_unit: string;
length?: string;
width?: string;
height?: string;
distance_unit?: string;
}>;
}): Promise<{ shipmentObjectId: string; rates: ShippoRate[] }> {
const apiKey = process.env.SHIPPO_API_KEY;
if (!apiKey) {
throw new ConvexError(
"Shipping rate service is unavailable (missing API key configuration).",
);
}
const requestBody = {
address_from: input.sourceAddressId,
address_to: input.destinationAddress,
parcels: input.parcels,
async: false,
};
let response: Response;
try {
response = await fetch(SHIPPO_SHIPMENTS_URL, {
method: "POST",
headers: {
Authorization: `ShippoToken ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
} catch {
throw new ConvexError(
"Shipping rate service is unreachable. Please try again later.",
);
}
if (!response.ok) {
let errorDetail = "";
try {
const errBody = await response.json();
errorDetail = JSON.stringify(errBody);
console.error("Shippo /shipments/ error:", response.status, errorDetail);
} catch {
console.error("Shippo /shipments/ error:", response.status, "(no parseable body)");
}
throw new ConvexError(
`Shipping rate service unavailable (status ${response.status}).`,
);
}
let body: {
object_id: string;
messages?: Array<{ source: string; text: string }>;
rates: Array<{
object_id: string;
provider: string;
servicelevel: { name: string; token: string };
amount: string;
currency: string;
estimated_days: number;
duration_terms: string;
arrives_by?: string | null;
carrier_account: string;
}>;
};
try {
body = await response.json();
} catch {
throw new ConvexError(
"Shipping rate service returned an unexpected response. Please try again.",
);
}
if (body.rates.length === 0 && body.messages?.length) {
console.warn(
"Shippo returned 0 rates. Carrier messages:",
body.messages.map((m) => `[${m.source}] ${m.text}`).join(" | "),
);
}
const rates: ShippoRate[] = body.rates.map((rate) => ({
objectId: rate.object_id,
provider: rate.provider,
servicelevelName: rate.servicelevel.name,
servicelevelToken: rate.servicelevel.token,
amount: rate.amount,
currency: rate.currency,
estimatedDays: rate.estimated_days,
durationTerms: rate.duration_terms,
arrivesBy: rate.arrives_by ?? null,
carrierAccount: rate.carrier_account,
}));
return { shipmentObjectId: body.object_id, rates };
}
export function selectBestRate(rates: ShippoRate[]): {
selected: ShippoRate;
alternatives: ShippoRate[];
} {
if (rates.length === 0) {
throw new ConvexError(
"No shipping rates available for this address. Please verify your address and try again.",
);
}
const preferredLower = PREFERRED_CARRIERS.map((c) => c.toLowerCase());
const preferred = rates.filter((r) =>
preferredLower.includes(r.provider.toLowerCase()),
);
const sortByDaysThenPrice = (a: ShippoRate, b: ShippoRate) => {
const aDays = a.estimatedDays ?? Infinity;
const bDays = b.estimatedDays ?? Infinity;
const daysDiff = aDays - bDays;
if (daysDiff !== 0) return daysDiff;
return parseFloat(a.amount) - parseFloat(b.amount);
};
if (preferred.length > 0) {
preferred.sort(sortByDaysThenPrice);
return { selected: preferred[0], alternatives: preferred.slice(1, 3) };
}
console.warn(
"No preferred carriers returned rates. Falling back to all carriers.",
);
const sorted = [...rates].sort(sortByDaysThenPrice);
return { selected: sorted[0], alternatives: sorted.slice(1, 3) };
}

24
convex/model/stripe.ts Normal file
View File

@@ -0,0 +1,24 @@
"use node";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function getOrCreateStripeCustomer(input: {
stripeCustomerId: string | undefined;
email: string;
name: string;
convexUserId: string;
}): Promise<string> {
if (input.stripeCustomerId) {
return input.stripeCustomerId;
}
const customer = await stripe.customers.create({
email: input.email,
name: input.name,
metadata: { convexUserId: input.convexUserId },
});
return customer.id;
}

38
convex/model/users.ts Normal file
View File

@@ -0,0 +1,38 @@
import { QueryCtx, MutationCtx } from "../_generated/server";
import type { Id } from "../_generated/dataModel";
type AuthCtx = QueryCtx | MutationCtx;
export async function getCurrentUser(ctx: QueryCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query("users")
.withIndex("by_external_id", (q) => q.eq("externalId", identity.subject))
.unique();
}
export async function getCurrentUserOrThrow(ctx: AuthCtx) {
const user = await getCurrentUser(ctx as QueryCtx);
if (!user) throw new Error("Unauthenticated");
return user;
}
export async function requireAdmin(ctx: QueryCtx) {
const user = await getCurrentUserOrThrow(ctx);
if (user.role !== "admin" && user.role !== "super_admin") {
throw new Error("Unauthorized: admin access required");
}
return user;
}
export async function requireOwnership(
ctx: AuthCtx,
resourceUserId: Id<"users">,
) {
const user = await getCurrentUserOrThrow(ctx);
if (resourceUserId !== user._id) {
throw new Error("Unauthorized: resource does not belong to you");
}
return user;
}

564
convex/orders.test.ts Normal file
View File

@@ -0,0 +1,564 @@
import { convexTest } from "convex-test";
import { describe, it, expect, beforeEach } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
async function setupUserAndData(t: ReturnType<typeof convexTest>) {
const asCustomer = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
const userId = await asCustomer.mutation(api.users.store, {});
let categoryId: any;
let variantId: any;
await t.run(async (ctx) => {
categoryId = await ctx.db.insert("categories", {
name: "Toys",
slug: "toys",
});
const productId = await ctx.db.insert("products", {
name: "Ball",
slug: "ball",
status: "active",
categoryId,
tags: [],
parentCategorySlug: "toys",
childCategorySlug: "toys",
});
variantId = await ctx.db.insert("productVariants", {
productId,
name: "Red Ball",
sku: "BALL-RED-001",
price: 999,
stockQuantity: 50,
attributes: { color: "Red" },
isActive: true,
weight: 200,
weightUnit: "g",
});
});
return { asCustomer, variantId, userId };
}
async function setupAdminUser(t: ReturnType<typeof convexTest>) {
const asAdmin = t.withIdentity({
name: "Admin",
email: "admin@example.com",
subject: "clerk_admin_456",
});
const userId = await asAdmin.mutation(api.users.store, {});
await t.run(async (ctx) => {
await ctx.db.patch(userId, { role: "admin" });
});
return asAdmin;
}
describe("orders", () => {
it("listMine returns only the current user's orders", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
await makeConfirmedOrder(t, userId, variantId);
const result = await asCustomer.query(api.orders.listMine, {
paginationOpts: { numItems: 10, cursor: null },
});
expect(result.page).toHaveLength(1);
});
it("listMine returns empty for user with no orders", async () => {
const t = convexTest(schema, modules);
const asCustomer = t.withIdentity({
name: "Bob",
email: "bob@example.com",
subject: "clerk_bob_123",
});
await asCustomer.mutation(api.users.store, {});
const result = await asCustomer.query(api.orders.listMine, {
paginationOpts: { numItems: 10, cursor: null },
});
expect(result.page).toHaveLength(0);
});
it("getById throws if order belongs to a different user", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
const orderId = await makeConfirmedOrder(t, userId, variantId);
const asBob = t.withIdentity({
name: "Bob",
email: "bob@example.com",
subject: "clerk_bob_123",
});
await asBob.mutation(api.users.store, {});
await expect(
asBob.query(api.orders.getById, { id: orderId }),
).rejects.toThrow("Unauthorized: order does not belong to you");
});
it("create creates order and order items atomically", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
const orderId = await asCustomer.mutation(api.orders.create, {
shippingAddressSnapshot: {
fullName: "Alice Customer",
firstName: "Alice",
lastName: "Customer",
addressLine1: "123 Main St",
city: "Springfield",
postalCode: "62701",
country: "US",
},
billingAddressSnapshot: {
firstName: "Alice",
lastName: "Customer",
addressLine1: "123 Main St",
city: "Springfield",
postalCode: "62701",
country: "US",
},
items: [
{
variantId,
productName: "Ball",
variantName: "Red Ball",
sku: "BALL-RED-001",
quantity: 3,
unitPrice: 999,
},
],
shippingCost: 500,
discount: 100,
});
const order = await asCustomer.query(api.orders.getById, { id: orderId });
expect(order).not.toBeNull();
expect(order.subtotal).toBe(2997);
expect(order.total).toBe(2997 + 500 - 100 + 0); // subtotal + shipping - discount + tax
expect(order.items).toHaveLength(1);
expect(order.items[0].totalPrice).toBe(2997);
});
it("updateStatus throws for non-admin users", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
const orderId = await makeConfirmedOrder(t, userId, variantId);
await expect(
asCustomer.mutation(api.orders.updateStatus, {
id: orderId,
status: "shipped",
}),
).rejects.toThrow("Unauthorized: admin access required");
});
it("createFromCart creates order and clears cart on success", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
await asCustomer.mutation(api.carts.addItem, { variantId, quantity: 2 });
const orderId = await asCustomer.mutation(api.orders.createFromCart, {
shippingAddressSnapshot: {
fullName: "Alice Customer",
firstName: "Alice",
lastName: "Customer",
addressLine1: "123 Main St",
city: "Springfield",
postalCode: "62701",
country: "US",
},
billingAddressSnapshot: {
firstName: "Alice",
lastName: "Customer",
addressLine1: "123 Main St",
city: "Springfield",
postalCode: "62701",
country: "US",
},
shippingCost: 500,
discount: 0,
});
expect(orderId).toBeTruthy();
const order = await asCustomer.query(api.orders.getById, { id: orderId });
expect(order.items).toHaveLength(1);
expect(order.items[0].quantity).toBe(2);
const cart = await asCustomer.query(api.carts.get, {});
expect(cart!.items).toHaveLength(0);
});
it("createFromCart throws when item is out of stock; cart unchanged", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
await asCustomer.mutation(api.carts.addItem, { variantId, quantity: 10 });
await t.run(async (ctx) => {
await ctx.db.patch(variantId, { stockQuantity: 2 });
});
await expect(
asCustomer.mutation(api.orders.createFromCart, {
shippingAddressSnapshot: {
fullName: "Alice",
firstName: "Alice",
lastName: "Customer",
addressLine1: "123 Main St",
city: "Springfield",
postalCode: "62701",
country: "US",
},
billingAddressSnapshot: {
firstName: "Alice",
lastName: "Customer",
addressLine1: "123 Main St",
city: "Springfield",
postalCode: "62701",
country: "US",
},
shippingCost: 0,
discount: 0,
}),
).rejects.toThrow("One or more items are out of stock");
const cart = await asCustomer.query(api.carts.get, {});
expect(cart!.items).toHaveLength(1);
expect(cart!.items[0].quantity).toBe(10);
});
});
// ─── Helper reused across cancel + statusFilter tests ────────────────────────
/**
* Inserts a "confirmed" order + its items directly into the DB.
* Uses t.run() to bypass the create mutation validator so we can
* set all required schema fields precisely for cancel/statusFilter tests.
*/
async function makeConfirmedOrder(
t: ReturnType<typeof convexTest>,
userId: any,
variantId: any,
quantity = 2,
) {
let orderId: any;
await t.run(async (ctx) => {
orderId = await ctx.db.insert("orders", {
orderNumber: `ORD-TEST-${Math.random().toString(36).slice(2, 7).toUpperCase()}`,
userId,
email: "alice@example.com",
status: "confirmed",
paymentStatus: "paid",
subtotal: 999 * quantity,
tax: 0,
shipping: 500,
discount: 0,
total: 999 * quantity + 500,
currency: "GBP",
shippingAddressSnapshot: {
fullName: "Alice Smith",
firstName: "Alice",
lastName: "Smith",
addressLine1: "1 Test Lane",
city: "London",
postalCode: "E1 1AA",
country: "GB",
},
billingAddressSnapshot: {
firstName: "Alice",
lastName: "Smith",
addressLine1: "1 Test Lane",
city: "London",
postalCode: "E1 1AA",
country: "GB",
},
shippoShipmentId: "shp_test",
shippingMethod: "Standard",
shippingServiceCode: "std",
carrier: "DPD",
createdAt: Date.now(),
updatedAt: Date.now(),
});
await ctx.db.insert("orderItems", {
orderId,
variantId,
productName: "Ball",
variantName: "Red Ball",
sku: "BALL-RED-001",
quantity,
unitPrice: 999,
totalPrice: 999 * quantity,
});
});
return orderId;
}
describe("orders.cancel", () => {
it("cancels a confirmed order and sets status to cancelled", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
const orderId = await makeConfirmedOrder(t, userId, variantId);
const result = await asCustomer.mutation(api.orders.cancel, { id: orderId });
expect(result).toEqual({ success: true });
const order = await asCustomer.query(api.orders.getById, { id: orderId });
expect(order.status).toBe("cancelled");
});
it("restores variant stock after cancellation", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
let stockBefore: number | undefined;
await t.run(async (ctx) => {
const v = await ctx.db.get(variantId);
stockBefore = v?.stockQuantity;
});
const orderId = await makeConfirmedOrder(t, userId, variantId, 3);
await asCustomer.mutation(api.orders.cancel, { id: orderId });
await t.run(async (ctx) => {
const v = await ctx.db.get(variantId);
expect(v?.stockQuantity).toBe(stockBefore! + 3);
});
});
it("throws when order is in processing status", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
const orderId = await makeConfirmedOrder(t, userId, variantId);
await t.run(async (ctx) => {
await ctx.db.patch(orderId, { status: "processing" });
});
await expect(
asCustomer.mutation(api.orders.cancel, { id: orderId }),
).rejects.toThrow("Order has progressed past the cancellation window");
});
it("throws when order is in pending status", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
const orderId = await makeConfirmedOrder(t, userId, variantId);
await t.run(async (ctx) => {
await ctx.db.patch(orderId, { status: "pending" });
});
await expect(
asCustomer.mutation(api.orders.cancel, { id: orderId }),
).rejects.toThrow("Order is still awaiting payment confirmation.");
});
it("throws when trying to cancel an already cancelled order", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
const orderId = await makeConfirmedOrder(t, userId, variantId);
await asCustomer.mutation(api.orders.cancel, { id: orderId });
await expect(
asCustomer.mutation(api.orders.cancel, { id: orderId }),
).rejects.toThrow("Order is already cancelled.");
});
it("throws when cancelling another user's order", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
const orderId = await makeConfirmedOrder(t, userId, variantId);
const asBob = t.withIdentity({
name: "Bob",
email: "bob@example.com",
subject: "clerk_bob_cancel",
});
await asBob.mutation(api.users.store, {});
await expect(
asBob.mutation(api.orders.cancel, { id: orderId }),
).rejects.toThrow("Unauthorized: order does not belong to you");
});
it("throws when order id does not exist", async () => {
const t = convexTest(schema, modules);
const asCustomer = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_ghost",
});
await asCustomer.mutation(api.users.store, {});
// Use a valid-looking but non-existent order id by creating+deleting an order
const { variantId, userId } = await setupUserAndData(t);
const orderId = await makeConfirmedOrder(t, userId, variantId);
await t.run(async (ctx) => {
await ctx.db.delete(orderId);
});
await expect(
asCustomer.mutation(api.orders.cancel, { id: orderId }),
).rejects.toThrow("Order not found");
});
});
describe("orders.listMine with statusFilter", () => {
it("returns all orders when no statusFilter is provided", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
await makeConfirmedOrder(t, userId, variantId);
// Create a second order in default "pending" status via t.run
await t.run(async (ctx) => {
await ctx.db.insert("orders", {
orderNumber: `ORD-PENDING-${Date.now()}`,
userId,
email: "alice@example.com",
status: "pending",
paymentStatus: "pending",
subtotal: 999,
tax: 0,
shipping: 0,
discount: 0,
total: 999,
currency: "GBP",
shippingAddressSnapshot: {
fullName: "Alice Smith",
firstName: "Alice",
lastName: "Smith",
addressLine1: "1 Test Lane",
city: "London",
postalCode: "E1 1AA",
country: "GB",
},
billingAddressSnapshot: {
firstName: "Alice",
lastName: "Smith",
addressLine1: "1 Test Lane",
city: "London",
postalCode: "E1 1AA",
country: "GB",
},
shippoShipmentId: "",
shippingMethod: "",
shippingServiceCode: "",
carrier: "",
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const result = await asCustomer.query(api.orders.listMine, {
paginationOpts: { numItems: 10, cursor: null },
});
expect(result.page).toHaveLength(2);
});
it("filters to a single status when statusFilter contains one value", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
// "confirmed" order
await makeConfirmedOrder(t, userId, variantId);
// "pending" order via t.run
await t.run(async (ctx) => {
await ctx.db.insert("orders", {
orderNumber: `ORD-PENDING-${Date.now()}`,
userId,
email: "alice@example.com",
status: "pending",
paymentStatus: "pending",
subtotal: 999,
tax: 0,
shipping: 0,
discount: 0,
total: 999,
currency: "GBP",
shippingAddressSnapshot: {
fullName: "Alice Smith",
firstName: "Alice",
lastName: "Smith",
addressLine1: "1 Test Lane",
city: "London",
postalCode: "E1 1AA",
country: "GB",
},
billingAddressSnapshot: {
firstName: "Alice",
lastName: "Smith",
addressLine1: "1 Test Lane",
city: "London",
postalCode: "E1 1AA",
country: "GB",
},
shippoShipmentId: "",
shippingMethod: "",
shippingServiceCode: "",
carrier: "",
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const result = await asCustomer.query(api.orders.listMine, {
paginationOpts: { numItems: 10, cursor: null },
statusFilter: ["confirmed"],
});
expect(result.page).toHaveLength(1);
expect(result.page[0].status).toBe("confirmed");
});
it("filters to multiple statuses when statusFilter contains several values", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
// confirmed order
await makeConfirmedOrder(t, userId, variantId);
// processing order
const orderId2 = await makeConfirmedOrder(t, userId, variantId);
await t.run(async (ctx) => {
await ctx.db.patch(orderId2, { status: "processing" });
});
// delivered order
const orderId3 = await makeConfirmedOrder(t, userId, variantId);
await t.run(async (ctx) => {
await ctx.db.patch(orderId3, { status: "delivered" });
});
const result = await asCustomer.query(api.orders.listMine, {
paginationOpts: { numItems: 10, cursor: null },
statusFilter: ["confirmed", "processing"],
});
expect(result.page).toHaveLength(2);
expect(result.page.map((o: any) => o.status).sort()).toEqual([
"confirmed",
"processing",
]);
});
it("returns empty page when no orders match the statusFilter", async () => {
const t = convexTest(schema, modules);
const { asCustomer, variantId, userId } = await setupUserAndData(t);
await makeConfirmedOrder(t, userId, variantId);
const result = await asCustomer.query(api.orders.listMine, {
paginationOpts: { numItems: 10, cursor: null },
statusFilter: ["delivered"],
});
expect(result.page).toHaveLength(0);
});
});

547
convex/orders.ts Normal file
View File

@@ -0,0 +1,547 @@
import { query, mutation, internalMutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import * as Users from "./model/users";
import { getOrderWithItems, validateCartItems, canCustomerCancel } from "./model/orders";
import * as CartsModel from "./model/carts";
export const listMine = query({
args: {
paginationOpts: paginationOptsValidator,
statusFilter: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const base = ctx.db
.query("orders")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.order("desc");
if (args.statusFilter && args.statusFilter.length > 0) {
const [first, ...rest] = args.statusFilter;
return base
.filter((q) => {
const firstExpr = q.eq(q.field("status"), first as any);
return rest.reduce(
(acc: any, s) => q.or(acc, q.eq(q.field("status"), s as any)),
firstExpr,
);
})
.paginate(args.paginationOpts);
}
return base.paginate(args.paginationOpts);
},
});
export const cancel = mutation({
args: { id: v.id("orders") },
handler: async (ctx, { id }) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const order = await ctx.db.get(id);
if (!order) throw new Error("Order not found");
if (order.userId !== user._id)
throw new Error("Unauthorized: order does not belong to you");
const { allowed, reason } = canCustomerCancel(order);
if (!allowed) throw new Error(reason);
await ctx.db.patch(id, { status: "cancelled", updatedAt: Date.now() });
// Restore stock for each line item
const items = await ctx.db
.query("orderItems")
.withIndex("by_order", (q) => q.eq("orderId", id))
.collect();
for (const item of items) {
const variant = await ctx.db.get(item.variantId);
if (variant) {
await ctx.db.patch(item.variantId, {
stockQuantity: variant.stockQuantity + item.quantity,
});
}
}
return { success: true };
},
});
export const getById = query({
args: { id: v.id("orders") },
handler: async (ctx, { id }) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const order = await getOrderWithItems(ctx, id);
if (!order) throw new Error("Order not found");
const isAdmin = user.role === "admin" || user.role === "super_admin";
if (!isAdmin && order.userId !== user._id) {
throw new Error("Unauthorized: order does not belong to you");
}
return order;
},
});
export const listAll = query({
args: {
paginationOpts: paginationOptsValidator,
status: v.optional(v.string()),
paymentStatus: v.optional(v.string()),
},
handler: async (ctx, args) => {
await Users.requireAdmin(ctx);
let q;
if (args.status) {
q = ctx.db
.query("orders")
.withIndex("by_status", (idx) => idx.eq("status", args.status as any));
} else if (args.paymentStatus) {
q = ctx.db
.query("orders")
.withIndex("by_payment_status", (idx) =>
idx.eq("paymentStatus", args.paymentStatus as any),
);
} else {
q = ctx.db.query("orders");
}
return await q.order("desc").paginate(args.paginationOpts);
},
});
export const create = mutation({
args: {
shippingAddressSnapshot: v.object({
fullName: v.string(),
firstName: v.string(),
lastName: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
phone: v.optional(v.string()),
}),
billingAddressSnapshot: v.object({
firstName: v.string(),
lastName: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
}),
items: v.array(
v.object({
variantId: v.id("productVariants"),
productName: v.string(),
variantName: v.string(),
sku: v.string(),
quantity: v.number(),
unitPrice: v.number(),
imageUrl: v.optional(v.string()),
}),
),
shippingCost: v.number(),
tax: v.optional(v.number()),
discount: v.number(),
currency: v.optional(v.string()),
notes: v.optional(v.string()),
shippingMethod: v.optional(v.string()),
shippingServiceCode: v.optional(v.string()),
carrier: v.optional(v.string()),
shippoShipmentId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const subtotal = args.items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0,
);
const tax = args.tax ?? 0;
const total =
subtotal + args.shippingCost - args.discount + tax;
const now = Date.now();
const orderNumber = `ORD-${Math.random().toString(36).substring(2, 7).toUpperCase()}`;
const orderId = await ctx.db.insert("orders", {
orderNumber,
userId: user._id,
email: user.email,
status: "pending",
paymentStatus: "pending",
subtotal,
tax,
shipping: args.shippingCost,
discount: args.discount,
total,
currency: args.currency ?? "USD",
shippingAddressSnapshot: args.shippingAddressSnapshot,
billingAddressSnapshot: args.billingAddressSnapshot,
shippingMethod: args.shippingMethod ?? "",
shippingServiceCode: args.shippingServiceCode ?? "",
carrier: args.carrier ?? "",
shippoShipmentId: args.shippoShipmentId ?? "",
createdAt: now,
updatedAt: now,
notes: args.notes,
});
for (const item of args.items) {
await ctx.db.insert("orderItems", {
orderId,
variantId: item.variantId,
productName: item.productName,
variantName: item.variantName,
sku: item.sku,
quantity: item.quantity,
unitPrice: item.unitPrice,
totalPrice: item.unitPrice * item.quantity,
imageUrl: item.imageUrl,
});
}
return orderId;
},
});
const addressSnapshotValidator = {
shippingAddressSnapshot: v.object({
fullName: v.string(),
firstName: v.string(),
lastName: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
phone: v.optional(v.string()),
}),
billingAddressSnapshot: v.object({
firstName: v.string(),
lastName: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
}),
};
/**
* @deprecated Use `checkout.validateCart` instead — returns enriched items
* with weight/dimension data and richer issue types (price drift, inactive variants).
*
* No frontend code references this query. It can be removed once
* `createFromCart` is refactored to use `validateAndEnrichCart` from
* `model/checkout.ts` (planned for the Stripe webhook fulfillment phase).
*/
export const validateCart = query({
args: { sessionId: v.optional(v.string()) },
handler: async (ctx, args) => {
const user = await Users.getCurrentUser(ctx);
const userId = user?._id;
if (!userId && !args.sessionId) {
return { valid: true, outOfStock: [] };
}
const cart = await CartsModel.getCart(ctx, userId, args.sessionId);
if (!cart || cart.items.length === 0) {
return { valid: true, outOfStock: [] };
}
const outOfStock = await validateCartItems(ctx, cart.items);
return { valid: outOfStock.length === 0, outOfStock };
},
});
export const createFromCart = mutation({
args: {
...addressSnapshotValidator,
shippingCost: v.number(),
tax: v.optional(v.number()),
discount: v.number(),
currency: v.optional(v.string()),
notes: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const cart = await CartsModel.getCart(ctx, user._id);
if (!cart || cart.items.length === 0) {
throw new Error("Cart is empty");
}
const outOfStock = await validateCartItems(ctx, cart.items);
if (outOfStock.length > 0) {
throw new Error("One or more items are out of stock");
}
const orderItems: {
variantId: import("./_generated/dataModel").Id<"productVariants">;
productName: string;
variantName: string;
sku: string;
quantity: number;
unitPrice: number;
imageUrl?: string;
}[] = [];
for (const item of cart.items) {
if (!item.variantId) continue;
const variant = await ctx.db.get(item.variantId);
const product = await ctx.db.get(item.productId);
if (!variant || !product) 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);
orderItems.push({
variantId: item.variantId,
productName: product.name,
variantName: variant.name,
sku: variant.sku,
quantity: item.quantity,
unitPrice: item.price,
imageUrl: images[0]?.url,
});
}
if (orderItems.length === 0) {
throw new Error("Cart has no valid items");
}
const subtotal = orderItems.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0
);
const tax = args.tax ?? 0;
const total = subtotal + args.shippingCost - args.discount + tax;
const now = Date.now();
const orderNumber = `ORD-${Math.random().toString(36).substring(2, 7).toUpperCase()}`;
const orderId = await ctx.db.insert("orders", {
orderNumber,
userId: user._id,
email: user.email,
status: "pending",
paymentStatus: "pending",
subtotal,
tax,
shipping: args.shippingCost,
discount: args.discount,
total,
currency: args.currency ?? "USD",
shippingAddressSnapshot: args.shippingAddressSnapshot,
billingAddressSnapshot: args.billingAddressSnapshot,
shippoShipmentId: "",
shippingMethod: "",
shippingServiceCode: "",
carrier: "",
createdAt: now,
updatedAt: now,
notes: args.notes,
});
for (const item of orderItems) {
await ctx.db.insert("orderItems", {
orderId,
variantId: item.variantId,
productName: item.productName,
variantName: item.variantName,
sku: item.sku,
quantity: item.quantity,
unitPrice: item.unitPrice,
totalPrice: item.unitPrice * item.quantity,
imageUrl: item.imageUrl,
});
}
await ctx.db.patch(cart._id, { items: [], updatedAt: now });
return orderId;
},
});
export const updateStatus = mutation({
args: {
id: v.id("orders"),
status: v.union(
v.literal("pending"),
v.literal("confirmed"),
v.literal("processing"),
v.literal("shipped"),
v.literal("delivered"),
v.literal("cancelled"),
v.literal("refunded"),
),
},
handler: async (ctx, { id, status }) => {
await Users.requireAdmin(ctx);
const order = await ctx.db.get(id);
if (!order) throw new Error("Order not found");
await ctx.db.patch(id, { status });
return id;
},
});
export const fulfillFromCheckout = internalMutation({
args: {
stripeCheckoutSessionId: v.string(),
stripePaymentIntentId: v.union(v.string(), v.null()),
convexUserId: v.string(),
addressId: v.string(),
shipmentObjectId: v.string(),
shippingMethod: v.string(),
shippingServiceCode: v.string(),
carrier: v.string(),
amountTotal: v.union(v.number(), v.null()),
amountShipping: v.number(),
currency: v.union(v.string(), v.null()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("orders")
.withIndex("by_stripe_checkout_session_id", (q) =>
q.eq("stripeCheckoutSessionId", args.stripeCheckoutSessionId),
)
.unique();
if (existing) {
console.log(
"Order already exists for session:",
args.stripeCheckoutSessionId,
);
return existing._id;
}
const userId = args.convexUserId as Id<"users">;
const user = await ctx.db.get(userId);
if (!user) throw new Error("User not found");
const addressId = args.addressId as Id<"addresses">;
const address = await ctx.db.get(addressId);
if (!address) throw new Error("Address not found");
const cart = await CartsModel.getCart(ctx, userId);
if (!cart || cart.items.length === 0) throw new Error("Cart is empty");
const orderItems: Array<{
variantId: Id<"productVariants">;
productName: string;
variantName: string;
sku: string;
quantity: number;
unitPrice: number;
imageUrl: string | undefined;
}> = [];
for (const item of cart.items) {
if (!item.variantId) continue;
const variant = await ctx.db.get(item.variantId);
const product = await ctx.db.get(item.productId);
if (!variant || !product) 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);
orderItems.push({
variantId: item.variantId,
productName: product.name,
variantName: variant.name,
sku: variant.sku,
quantity: item.quantity,
unitPrice: variant.price,
imageUrl: images[0]?.url,
});
}
if (orderItems.length === 0) {
throw new Error("Cart has no valid items");
}
const subtotal = orderItems.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0,
);
const shipping = args.amountShipping;
const total = args.amountTotal ?? subtotal + shipping;
const now = Date.now();
const orderNumber = `ORD-${Math.random().toString(36).substring(2, 7).toUpperCase()}`;
const orderId = await ctx.db.insert("orders", {
orderNumber,
userId,
email: user.email,
status: "confirmed",
paymentStatus: "paid",
subtotal,
tax: 0,
shipping,
discount: 0,
total,
currency: args.currency ?? "gbp",
shippingAddressSnapshot: {
fullName: address.fullName,
firstName: address.firstName,
lastName: address.lastName,
addressLine1: address.addressLine1,
additionalInformation: address.additionalInformation,
city: address.city,
postalCode: address.postalCode,
country: address.country,
phone: address.phone,
},
billingAddressSnapshot: {
firstName: address.firstName,
lastName: address.lastName,
addressLine1: address.addressLine1,
additionalInformation: address.additionalInformation,
city: address.city,
postalCode: address.postalCode,
country: address.country,
},
stripeCheckoutSessionId: args.stripeCheckoutSessionId,
stripePaymentIntentId: args.stripePaymentIntentId ?? undefined,
shippoShipmentId: args.shipmentObjectId,
shippingMethod: args.shippingMethod,
shippingServiceCode: args.shippingServiceCode,
carrier: args.carrier,
createdAt: now,
updatedAt: now,
paidAt: now,
});
for (const item of orderItems) {
await ctx.db.insert("orderItems", {
orderId,
variantId: item.variantId,
productName: item.productName,
variantName: item.variantName,
sku: item.sku,
quantity: item.quantity,
unitPrice: item.unitPrice,
totalPrice: item.unitPrice * item.quantity,
imageUrl: item.imageUrl,
});
}
for (const item of orderItems) {
const variant = await ctx.db.get(item.variantId);
if (variant) {
await ctx.db.patch(item.variantId, {
stockQuantity: variant.stockQuantity - item.quantity,
});
}
}
await ctx.db.patch(cart._id, { items: [], updatedAt: now });
return orderId;
},
});

840
convex/products.test.ts Normal file
View File

@@ -0,0 +1,840 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
async function setupAdminUser(t: ReturnType<typeof convexTest>) {
const asAdmin = t.withIdentity({
name: "Admin",
email: "admin@example.com",
subject: "clerk_admin_123",
});
const userId = await asAdmin.mutation(api.users.store, {});
await t.run(async (ctx) => {
await ctx.db.patch(userId, { role: "admin" });
});
return asAdmin;
}
async function setupCategory(t: ReturnType<typeof convexTest>) {
let categoryId: any;
await t.run(async (ctx) => {
categoryId = await ctx.db.insert("categories", {
name: "Pet Food",
slug: "pet-food",
});
});
return categoryId;
}
describe("products", () => {
it("list returns empty page when no products", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.list, {
paginationOpts: { numItems: 10, cursor: null },
});
expect(result.page).toHaveLength(0);
});
it("list returns only active products when filtered by status", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Active Product",
slug: "active-product",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Draft Product",
slug: "draft-product",
status: "draft",
categoryId,
tags: [],
});
const result = await t.query(api.products.list, {
paginationOpts: { numItems: 10, cursor: null },
status: "active",
});
expect(result.page).toHaveLength(1);
expect(result.page[0].name).toBe("Active Product");
});
it("getBySlug returns product for valid slug", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Dog Treats",
slug: "dog-treats",
status: "active",
categoryId,
tags: ["dogs"],
});
const product = await t.query(api.products.getBySlug, {
slug: "dog-treats",
});
expect(product).not.toBeNull();
expect(product?.name).toBe("Dog Treats");
});
it("getBySlug returns null for non-existent slug", async () => {
const t = convexTest(schema, modules);
const product = await t.query(api.products.getBySlug, {
slug: "does-not-exist",
});
expect(product).toBeNull();
});
it("create throws for non-admin users", async () => {
const t = convexTest(schema, modules);
const categoryId = await setupCategory(t);
const asCustomer = t.withIdentity({
name: "Customer",
email: "customer@example.com",
subject: "clerk_customer_123",
});
await asCustomer.mutation(api.users.store, {});
await expect(
asCustomer.mutation(api.products.create, {
name: "Illegal Product",
slug: "illegal-product",
status: "active",
categoryId,
tags: [],
}),
).rejects.toThrow("Unauthorized: admin access required");
});
it("create succeeds for admin users", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Cat Toy",
slug: "cat-toy",
status: "draft",
categoryId,
tags: ["cats"],
});
expect(productId).toBeTruthy();
});
it("archive changes product status to archived", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Old Product",
slug: "old-product",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.archive, { id: productId });
const product = await t.query(api.products.getBySlug, {
slug: "old-product",
});
expect(product).toBeNull();
});
it("addVariant creates variant linked to product", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Variant Product",
slug: "variant-product",
status: "active",
categoryId,
tags: [],
});
const variantId = await asAdmin.mutation(api.products.addVariant, {
productId,
name: "Small",
sku: "VAR-SM-001",
price: 999,
stockQuantity: 10,
isActive: true,
});
expect(variantId).toBeTruthy();
const product = await t.query(api.products.getBySlug, {
slug: "variant-product",
});
expect(product).not.toBeNull();
expect(product?.variants).toHaveLength(1);
expect(product?.variants[0].name).toBe("Small");
expect(product?.variants[0].sku).toBe("VAR-SM-001");
expect(product?.variants[0].price).toBe(999);
});
it("updateVariant changes variant price", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Update Variant Product",
slug: "update-variant-product",
status: "active",
categoryId,
tags: [],
});
const variantId = await asAdmin.mutation(api.products.addVariant, {
productId,
name: "Medium",
sku: "VAR-MD-001",
price: 1999,
stockQuantity: 5,
isActive: true,
});
await asAdmin.mutation(api.products.updateVariant, {
id: variantId,
price: 1499,
});
const product = await t.query(api.products.getBySlug, {
slug: "update-variant-product",
});
expect(product?.variants[0].price).toBe(1499);
});
it("addImage inserts image with correct position", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Image Product",
slug: "image-product",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.addImage, {
productId,
url: "https://example.com/first.jpg",
alt: "First image",
position: 1,
});
await asAdmin.mutation(api.products.addImage, {
productId,
url: "https://example.com/second.jpg",
alt: "Second image",
position: 0,
});
const product = await t.query(api.products.getBySlug, {
slug: "image-product",
});
expect(product?.images).toHaveLength(2);
expect(product?.images[0].position).toBe(0);
expect(product?.images[0].url).toBe("https://example.com/second.jpg");
expect(product?.images[1].position).toBe(1);
expect(product?.images[1].url).toBe("https://example.com/first.jpg");
});
it("reorderImages updates position values", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Reorder Product",
slug: "reorder-product",
status: "active",
categoryId,
tags: [],
});
const id1 = await asAdmin.mutation(api.products.addImage, {
productId,
url: "https://example.com/a.jpg",
position: 0,
});
const id2 = await asAdmin.mutation(api.products.addImage, {
productId,
url: "https://example.com/b.jpg",
position: 1,
});
await asAdmin.mutation(api.products.reorderImages, {
updates: [
{ id: id1, position: 1 },
{ id: id2, position: 0 },
],
});
const product = await t.query(api.products.getBySlug, {
slug: "reorder-product",
});
expect(product?.images).toHaveLength(2);
expect(product?.images[0].position).toBe(0);
expect(product?.images[0].url).toBe("https://example.com/b.jpg");
expect(product?.images[1].position).toBe(1);
expect(product?.images[1].url).toBe("https://example.com/a.jpg");
});
it("search returns products matching query string", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Dog Treats",
slug: "dog-treats",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Cat Food",
slug: "cat-food",
status: "active",
categoryId,
tags: [],
});
const result = await t.query(api.products.search, { query: "Dog" });
expect(result).toHaveLength(1);
expect(result[0].name).toContain("Dog");
});
it("search respects status filter", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Active Treats",
slug: "active-treats",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Draft Treats",
slug: "draft-treats",
status: "draft",
categoryId,
tags: [],
});
const result = await t.query(api.products.search, {
query: "Treats",
status: "active",
});
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Active Treats");
});
it("search with no matches returns empty array", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.search, {
query: "xyznonexistent",
});
expect(result).toEqual([]);
});
describe("searchTypeahead", () => {
async function setupHierarchicalCategories(t: ReturnType<typeof convexTest>) {
let dogsChildCategoryId: any;
let catsChildCategoryId: any;
await t.run(async (ctx) => {
const dogsId = await ctx.db.insert("categories", {
name: "Dogs",
slug: "dogs",
});
dogsChildCategoryId = await ctx.db.insert("categories", {
name: "Dog Food",
slug: "dog-food",
parentId: dogsId,
});
const catsId = await ctx.db.insert("categories", {
name: "Cats",
slug: "cats",
});
catsChildCategoryId = await ctx.db.insert("categories", {
name: "Cat Food",
slug: "cat-food",
parentId: catsId,
});
});
return { dogsChildCategoryId, catsChildCategoryId };
}
it("returns empty array for query shorter than 3 characters", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.searchTypeahead, { query: "do" });
expect(result).toEqual([]);
});
it("returns empty array for query of exactly 2 characters", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.searchTypeahead, { query: "ab" });
expect(result).toEqual([]);
});
it("returns empty array when no products match the query", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.searchTypeahead, {
query: "xyznonexistent",
});
expect(result).toEqual([]);
});
it("returns active products matching the query", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Premium Dog Kibble",
slug: "premium-dog-kibble",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Cat Litter Deluxe",
slug: "cat-litter-deluxe",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Kibble",
});
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Premium Dog Kibble");
});
it("does not return draft or archived products", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Active Bone Treat",
slug: "active-bone-treat",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Draft Bone Treat",
slug: "draft-bone-treat",
status: "draft",
categoryId: dogsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Bone",
});
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Active Bone Treat");
});
it("filters by parentCategorySlug when provided", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId, catsChildCategoryId } =
await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Royal Canin Dog Food",
slug: "royal-canin-dog-food",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Royal Canin Cat Food",
slug: "royal-canin-cat-food",
status: "active",
categoryId: catsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Royal Canin",
parentCategorySlug: "cats",
});
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Royal Canin Cat Food");
expect(result[0].parentCategorySlug).toBe("cats");
});
it("returns all matching products when no category filter is provided", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId, catsChildCategoryId } =
await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Pro Plan Dog Food",
slug: "pro-plan-dog-food",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Pro Plan Cat Food",
slug: "pro-plan-cat-food",
status: "active",
categoryId: catsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Pro Plan",
});
expect(result).toHaveLength(2);
});
it("returns enriched data: imageUrl, minPrice, slug, and category slugs", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Enriched Dog Treat",
slug: "enriched-dog-treat",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.addImage, {
productId,
url: "https://example.com/treat.jpg",
alt: "Dog Treat",
position: 0,
});
await asAdmin.mutation(api.products.addVariant, {
productId,
name: "100g",
sku: "TREAT-100G",
price: 599,
stockQuantity: 50,
isActive: true,
});
const result = await t.query(api.products.searchTypeahead, {
query: "Enriched",
});
expect(result).toHaveLength(1);
const item = result[0];
expect(item.slug).toBe("enriched-dog-treat");
expect(item.parentCategorySlug).toBe("dogs");
expect(item.childCategorySlug).toBe("dog-food");
expect(item.imageUrl).toBe("https://example.com/treat.jpg");
expect(item.imageAlt).toBe("Dog Treat");
expect(item.minPrice).toBe(599);
});
it("returns minPrice=0 when product has no active variants", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Variantless Collar",
slug: "variantless-collar",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Variantless",
});
expect(result).toHaveLength(1);
expect(result[0].minPrice).toBe(0);
});
it("returns imageUrl as undefined when product has no images", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
await asAdmin.mutation(api.products.create, {
name: "Imageless Dog Toy",
slug: "imageless-dog-toy",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
const result = await t.query(api.products.searchTypeahead, {
query: "Imageless",
});
expect(result).toHaveLength(1);
expect(result[0].imageUrl).toBeUndefined();
});
it("picks the lowest priced active variant as minPrice", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
const productId = await asAdmin.mutation(api.products.create, {
name: "Multi-Variant Dog Food",
slug: "multi-variant-dog-food",
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
await asAdmin.mutation(api.products.addVariant, {
productId,
name: "1kg",
sku: "MV-1KG",
price: 1200,
stockQuantity: 10,
isActive: true,
});
await asAdmin.mutation(api.products.addVariant, {
productId,
name: "500g",
sku: "MV-500G",
price: 799,
stockQuantity: 20,
isActive: true,
});
// Inactive variant with a lower price — should be ignored
await asAdmin.mutation(api.products.addVariant, {
productId,
name: "2kg",
sku: "MV-2KG",
price: 199,
stockQuantity: 5,
isActive: false,
});
const result = await t.query(api.products.searchTypeahead, {
query: "Multi-Variant",
});
expect(result).toHaveLength(1);
expect(result[0].minPrice).toBe(799);
});
it("respects the limit parameter", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const { dogsChildCategoryId } = await setupHierarchicalCategories(t);
for (let i = 0; i < 5; i++) {
await asAdmin.mutation(api.products.create, {
name: `Dog Snack ${i}`,
slug: `dog-snack-${i}`,
status: "active",
categoryId: dogsChildCategoryId,
tags: [],
});
}
const result = await t.query(api.products.searchTypeahead, {
query: "Dog Snack",
limit: 3,
});
expect(result).toHaveLength(3);
});
});
describe("listByTag", () => {
it("returns only active products with the given tag", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Sale Product",
slug: "sale-product",
status: "active",
categoryId,
tags: ["sale", "dogs"],
});
await asAdmin.mutation(api.products.create, {
name: "Top Pick",
slug: "top-pick",
status: "active",
categoryId,
tags: ["top-picks"],
});
await asAdmin.mutation(api.products.create, {
name: "Regular Product",
slug: "regular-product",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Draft Sale",
slug: "draft-sale",
status: "draft",
categoryId,
tags: ["sale"],
});
const result = await t.query(api.products.listByTag, { tag: "sale" });
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Sale Product");
});
it("returns empty array when no products match the tag", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Regular Product",
slug: "regular-product",
status: "active",
categoryId,
tags: ["dogs"],
});
const result = await t.query(api.products.listByTag, { tag: "sale" });
expect(result).toHaveLength(0);
});
it("respects the limit arg", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
for (let i = 0; i < 5; i++) {
await asAdmin.mutation(api.products.create, {
name: `Sale Product ${i}`,
slug: `sale-product-${i}`,
status: "active",
categoryId,
tags: ["sale"],
});
}
const result = await t.query(api.products.listByTag, {
tag: "sale",
limit: 3,
});
expect(result).toHaveLength(3);
});
it("returns products matching top-picks tag", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
await asAdmin.mutation(api.products.create, {
name: "Top Pick A",
slug: "top-pick-a",
status: "active",
categoryId,
tags: ["top-picks"],
});
await asAdmin.mutation(api.products.create, {
name: "Top Pick B",
slug: "top-pick-b",
status: "active",
categoryId,
tags: ["top-picks", "sale"],
});
await asAdmin.mutation(api.products.create, {
name: "No Pick",
slug: "no-pick",
status: "active",
categoryId,
tags: ["sale"],
});
const result = await t.query(api.products.listByTag, { tag: "top-picks" });
expect(result).toHaveLength(2);
const names = result.map((p: any) => p.name).sort();
expect(names).toEqual(["Top Pick A", "Top Pick B"]);
});
});
describe("listRecentlyAdded", () => {
it("returns active products created within the last 30 days", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
// These will have _creationTime = now (within 30 days)
await asAdmin.mutation(api.products.create, {
name: "New Product A",
slug: "new-product-a",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "New Product B",
slug: "new-product-b",
status: "active",
categoryId,
tags: [],
});
await asAdmin.mutation(api.products.create, {
name: "Draft New",
slug: "draft-new",
status: "draft",
categoryId,
tags: [],
});
const result = await t.query(api.products.listRecentlyAdded, {});
// Only the two active products should appear; draft is excluded
expect(result.length).toBeGreaterThanOrEqual(2);
const names = result.map((p: any) => p.name);
expect(names).toContain("New Product A");
expect(names).toContain("New Product B");
expect(names).not.toContain("Draft New");
});
it("respects the limit arg", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const categoryId = await setupCategory(t);
for (let i = 0; i < 5; i++) {
await asAdmin.mutation(api.products.create, {
name: `Product ${i}`,
slug: `product-${i}`,
status: "active",
categoryId,
tags: [],
});
}
const result = await t.query(api.products.listRecentlyAdded, { limit: 3 });
expect(result).toHaveLength(3);
});
it("returns empty array when no active products exist", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.products.listRecentlyAdded, {});
expect(result).toHaveLength(0);
});
});
});

929
convex/products.ts Normal file
View File

@@ -0,0 +1,929 @@
import { query, mutation, internalQuery, internalMutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import type { Id, Doc } from "./_generated/dataModel";
import * as Users from "./model/users";
import { getProductWithRelations, enrichProducts } from "./model/products";
export const list = query({
args: {
paginationOpts: paginationOptsValidator,
status: v.optional(v.string()),
categoryId: v.optional(v.id("categories")),
},
handler: async (ctx, args) => {
let q;
if (args.status && args.categoryId) {
q = ctx.db
.query("products")
.withIndex("by_status_and_category", (idx) =>
idx.eq("status", args.status as any).eq("categoryId", args.categoryId!),
);
} else if (args.status) {
q = ctx.db
.query("products")
.withIndex("by_status", (idx) => idx.eq("status", args.status as any));
} else if (args.categoryId) {
q = ctx.db
.query("products")
.withIndex("by_category", (idx) =>
idx.eq("categoryId", args.categoryId!),
);
} else {
q = ctx.db.query("products");
}
const result = await q.paginate(args.paginationOpts);
const enrichedPage = await enrichProducts(ctx, result.page as any);
return { ...result, page: enrichedPage };
},
});
const LIST_ALL_LIMIT = 500;
export const listAll = query({
args: {
status: v.optional(v.string()),
categoryId: v.optional(v.id("categories")),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = Math.min(args.limit ?? LIST_ALL_LIMIT, 1000);
let q;
if (args.status && args.categoryId) {
q = ctx.db
.query("products")
.withIndex("by_status_and_category", (idx) =>
idx.eq("status", args.status as any).eq("categoryId", args.categoryId!),
);
} else if (args.status) {
q = ctx.db
.query("products")
.withIndex("by_status", (idx) => idx.eq("status", args.status as any));
} else if (args.categoryId) {
q = ctx.db
.query("products")
.withIndex("by_category", (idx) =>
idx.eq("categoryId", args.categoryId!),
);
} else {
q = ctx.db.query("products");
}
const page = await q.take(limit);
return enrichProducts(ctx, page as any);
},
});
const SHOP_LIST_LIMIT = 200;
// Filter args: optional brand, tags (product has any), attributes (overlap per dimension).
const shopFilterArgsValidator = {
brand: v.optional(v.string()),
tags: v.optional(v.array(v.string())),
attributes: v.optional(
v.object({
petSize: v.optional(v.array(v.string())),
ageRange: v.optional(v.array(v.string())),
specialDiet: v.optional(v.array(v.string())),
material: v.optional(v.string()),
flavor: v.optional(v.string()),
}),
),
};
type ShopFilterArgs = {
brand?: string;
tags?: string[];
attributes?: {
petSize?: string[];
ageRange?: string[];
specialDiet?: string[];
material?: string;
flavor?: string;
};
};
function productMatchesFilters(
p: { brand?: string; tags: string[]; attributes?: { petSize?: string[]; ageRange?: string[]; specialDiet?: string[]; material?: string; flavor?: string } },
filters: ShopFilterArgs,
): boolean {
if (filters.brand != null && filters.brand !== "" && p.brand !== filters.brand) return false;
if (filters.tags != null && filters.tags.length > 0) {
const hasAny = (p.tags ?? []).some((t) => filters.tags!.includes(t));
if (!hasAny) return false;
}
const attrs = filters.attributes;
if (attrs) {
if (attrs.petSize != null && attrs.petSize.length > 0) {
const productVals = p.attributes?.petSize ?? [];
if (!attrs.petSize.some((v) => productVals.includes(v))) return false;
}
if (attrs.ageRange != null && attrs.ageRange.length > 0) {
const productVals = p.attributes?.ageRange ?? [];
if (!attrs.ageRange.some((v) => productVals.includes(v))) return false;
}
if (attrs.specialDiet != null && attrs.specialDiet.length > 0) {
const productVals = p.attributes?.specialDiet ?? [];
if (!attrs.specialDiet.some((v) => productVals.includes(v))) return false;
}
if (attrs.material != null && attrs.material !== "") {
if (p.attributes?.material !== attrs.material) return false;
}
if (attrs.flavor != null && attrs.flavor !== "") {
if (p.attributes?.flavor !== attrs.flavor) return false;
}
}
return true;
}
export const listActive = query({
args: {
categoryId: v.optional(v.id("categories")),
limit: v.optional(v.number()),
...shopFilterArgsValidator,
},
handler: async (ctx, args) => {
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
const filters: ShopFilterArgs = {
brand: args.brand,
tags: args.tags,
attributes: args.attributes,
};
let q;
if (args.categoryId) {
q = ctx.db
.query("products")
.withIndex("by_status_and_category", (idx) =>
idx.eq("status", "active").eq("categoryId", args.categoryId!),
);
} else {
q = ctx.db
.query("products")
.withIndex("by_status", (idx) => idx.eq("status", "active"));
}
const hasFilters = filters.brand != null || (filters.tags?.length ?? 0) > 0 || filters.attributes != null;
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit;
const page = await q.take(takeCount);
const filtered = (page as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit);
return enrichProducts(ctx, filtered as any);
},
});
export const listByRootCategory = query({
args: {
rootCategoryId: v.id("categories"),
limit: v.optional(v.number()),
...shopFilterArgsValidator,
},
handler: async (ctx, args) => {
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
const filters: ShopFilterArgs = {
brand: args.brand,
tags: args.tags,
attributes: args.attributes,
};
const root = await ctx.db.get(args.rootCategoryId);
if (!root) return [];
const parentCategorySlug = root.slug;
const hasFilters =
filters.brand != null ||
(filters.tags?.length ?? 0) > 0 ||
filters.attributes != null;
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit;
const products = await ctx.db
.query("products")
.withIndex("by_status_and_parent_slug", (q) =>
q.eq("status", "active").eq("parentCategorySlug", parentCategorySlug),
)
.take(takeCount);
const filtered = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit);
return enrichProducts(ctx, filtered as any);
},
});
export const listByTopCategory = query({
args: {
topCategorySlug: v.string(),
petCategorySlug: v.optional(v.string()),
limit: v.optional(v.number()),
...shopFilterArgsValidator,
},
handler: async (ctx, args) => {
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
const filters: ShopFilterArgs = {
brand: args.brand,
tags: args.tags,
attributes: args.attributes,
};
const hasFilters =
filters.brand != null ||
(filters.tags?.length ?? 0) > 0 ||
filters.attributes != null;
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit;
let products;
if (args.petCategorySlug) {
products = await ctx.db
.query("products")
.withIndex("by_status_and_top_and_parent_slug", (q) =>
q
.eq("status", "active")
.eq("topCategorySlug", args.topCategorySlug)
.eq("parentCategorySlug", args.petCategorySlug!),
)
.take(takeCount);
} else {
products = await ctx.db
.query("products")
.withIndex("by_status_and_top_category_slug", (q) =>
q.eq("status", "active").eq("topCategorySlug", args.topCategorySlug),
)
.take(takeCount);
}
const filteredProducts = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit);
return enrichProducts(ctx, filteredProducts as any);
},
});
export const listByParentSlug = query({
args: {
parentCategorySlug: v.string(),
limit: v.optional(v.number()),
...shopFilterArgsValidator,
},
handler: async (ctx, args) => {
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
const filters: ShopFilterArgs = {
brand: args.brand,
tags: args.tags,
attributes: args.attributes,
};
const hasFilters =
filters.brand != null ||
(filters.tags?.length ?? 0) > 0 ||
filters.attributes != null;
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit;
const products = await ctx.db
.query("products")
.withIndex("by_status_and_parent_slug", (q) =>
q.eq("status", "active").eq("parentCategorySlug", args.parentCategorySlug),
)
.take(takeCount);
const filtered = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit);
return enrichProducts(ctx, filtered as any);
},
});
export const listByTag = query({
args: {
tag: v.string(),
limit: v.optional(v.number()),
...shopFilterArgsValidator,
},
handler: async (ctx, args) => {
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
const filters: ShopFilterArgs = {
brand: args.brand,
tags: args.tags,
attributes: args.attributes,
};
const hasFilters =
filters.brand != null ||
(filters.tags?.length ?? 0) > 0 ||
filters.attributes != null;
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit * 5;
const page = await ctx.db
.query("products")
.withIndex("by_status", (q) => q.eq("status", "active"))
.take(takeCount);
const filtered = (page as any[])
.filter((p: any) => (p.tags ?? []).includes(args.tag))
.filter((p: any) => productMatchesFilters(p, filters))
.slice(0, limit);
return enrichProducts(ctx, filtered as any);
},
});
const RECENTLY_ADDED_LIMIT = 100;
const RECENTLY_ADDED_SCAN_CEILING = 500;
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
export const listRecentlyAdded = query({
args: {
limit: v.optional(v.number()),
...shopFilterArgsValidator,
},
handler: async (ctx, args) => {
const limit = Math.min(args.limit ?? RECENTLY_ADDED_LIMIT, RECENTLY_ADDED_LIMIT);
const filters: ShopFilterArgs = {
brand: args.brand,
tags: args.tags,
attributes: args.attributes,
};
const cutoff = Date.now() - THIRTY_DAYS_MS;
const collected: any[] = [];
let scanned = 0;
for await (const p of ctx.db
.query("products")
.withIndex("by_status", (q: any) => q.eq("status", "active"))
.order("desc")) {
if (p._creationTime < cutoff) break;
if (scanned++ >= RECENTLY_ADDED_SCAN_CEILING) break;
if (productMatchesFilters(p as any, filters)) {
collected.push(p);
if (collected.length >= limit) break;
}
}
return enrichProducts(ctx, collected as any);
},
});
export const listByParentAndChildSlug = query({
args: {
parentCategorySlug: v.string(),
childCategorySlug: v.string(),
limit: v.optional(v.number()),
...shopFilterArgsValidator,
},
handler: async (ctx, args) => {
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
const filters: ShopFilterArgs = {
brand: args.brand,
tags: args.tags,
attributes: args.attributes,
};
const hasFilters =
filters.brand != null ||
(filters.tags?.length ?? 0) > 0 ||
filters.attributes != null;
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit;
const products = await ctx.db
.query("products")
.withIndex("by_status_and_parent_and_child_slug", (q) =>
q
.eq("status", "active")
.eq("parentCategorySlug", args.parentCategorySlug)
.eq("childCategorySlug", args.childCategorySlug),
)
.take(takeCount);
const filtered = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit);
return enrichProducts(ctx, filtered as any);
},
});
// ─── Filter options (derived from products in scope) ───────────────────────
const FILTER_OPTIONS_PRODUCT_LIMIT = 2000;
async function getProductsInScope(
ctx: { db: any },
scope: {
categoryId?: Id<"categories">;
rootCategoryId?: Id<"categories">;
parentCategorySlug?: string;
childCategorySlug?: string;
topCategorySlug?: string;
petCategorySlug?: string;
tag?: string;
recentlyAdded?: boolean;
},
): Promise<Array<{ brand?: string; tags: string[]; attributes?: { petSize?: string[]; ageRange?: string[]; specialDiet?: string[]; material?: string; flavor?: string } }>> {
// Resolve which scope is used and fetch raw products (no enrichment needed for aggregation).
if (scope.categoryId !== undefined) {
return ctx.db
.query("products")
.withIndex("by_status_and_category", (q: any) =>
q.eq("status", "active").eq("categoryId", scope.categoryId),
)
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
}
if (scope.rootCategoryId !== undefined) {
const root = await ctx.db.get(scope.rootCategoryId);
if (!root) return [];
return ctx.db
.query("products")
.withIndex("by_status_and_parent_slug", (q: any) =>
q.eq("status", "active").eq("parentCategorySlug", root.slug),
)
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
}
if (scope.parentCategorySlug !== undefined && scope.childCategorySlug !== undefined) {
return ctx.db
.query("products")
.withIndex("by_status_and_parent_and_child_slug", (q: any) =>
q
.eq("status", "active")
.eq("parentCategorySlug", scope.parentCategorySlug)
.eq("childCategorySlug", scope.childCategorySlug),
)
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
}
if (scope.parentCategorySlug !== undefined) {
return ctx.db
.query("products")
.withIndex("by_status_and_parent_slug", (q: any) =>
q.eq("status", "active").eq("parentCategorySlug", scope.parentCategorySlug),
)
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
}
if (scope.topCategorySlug !== undefined) {
if (scope.petCategorySlug) {
return ctx.db
.query("products")
.withIndex("by_status_and_top_and_parent_slug", (q: any) =>
q
.eq("status", "active")
.eq("topCategorySlug", scope.topCategorySlug)
.eq("parentCategorySlug", scope.petCategorySlug),
)
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
}
return ctx.db
.query("products")
.withIndex("by_status_and_top_category_slug", (q: any) =>
q.eq("status", "active").eq("topCategorySlug", scope.topCategorySlug),
)
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
}
if (scope.tag !== undefined) {
const all = await ctx.db
.query("products")
.withIndex("by_status", (q: any) => q.eq("status", "active"))
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
return all.filter((p: any) => (p.tags ?? []).includes(scope.tag!));
}
if (scope.recentlyAdded === true) {
const cutoff = Date.now() - THIRTY_DAYS_MS;
const results: any[] = [];
for await (const p of ctx.db
.query("products")
.withIndex("by_status", (q: any) => q.eq("status", "active"))
.order("desc")) {
if (p._creationTime < cutoff) break;
results.push(p);
if (results.length >= FILTER_OPTIONS_PRODUCT_LIMIT) break;
}
return results;
}
// No scope: entire catalog
return ctx.db
.query("products")
.withIndex("by_status", (q: any) => q.eq("status", "active"))
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
}
function aggregateFilterOptions(
products: Array<{ brand?: string; tags: string[]; attributes?: { petSize?: string[]; ageRange?: string[]; specialDiet?: string[]; material?: string; flavor?: string } }>,
): {
brands: string[];
tags: string[];
attributes: { petSize: string[]; ageRange: string[]; specialDiet: string[]; material: string[]; flavor: string[] };
} {
const brandsSet = new Set<string>();
const tagsSet = new Set<string>();
const petSizeSet = new Set<string>();
const ageRangeSet = new Set<string>();
const specialDietSet = new Set<string>();
const materialSet = new Set<string>();
const flavorSet = new Set<string>();
for (const p of products) {
if (p.brand != null && p.brand.trim() !== "") brandsSet.add(p.brand);
for (const t of p.tags ?? []) if (t != null && String(t).trim() !== "") tagsSet.add(String(t));
const attrs = p.attributes;
if (attrs) {
for (const v of attrs.petSize ?? []) if (v != null && String(v).trim() !== "") petSizeSet.add(String(v));
for (const v of attrs.ageRange ?? []) if (v != null && String(v).trim() !== "") ageRangeSet.add(String(v));
for (const v of attrs.specialDiet ?? []) if (v != null && String(v).trim() !== "") specialDietSet.add(String(v));
if (attrs.material != null && String(attrs.material).trim() !== "") materialSet.add(String(attrs.material));
if (attrs.flavor != null && String(attrs.flavor).trim() !== "") flavorSet.add(String(attrs.flavor));
}
}
const sort = (a: string, b: string) => a.localeCompare(b, "en");
return {
brands: [...brandsSet].sort(sort),
tags: [...tagsSet].sort(sort),
attributes: {
petSize: [...petSizeSet].sort(sort),
ageRange: [...ageRangeSet].sort(sort),
specialDiet: [...specialDietSet].sort(sort),
material: [...materialSet].sort(sort),
flavor: [...flavorSet].sort(sort),
},
};
}
export const getFilterOptions = query({
args: {
categoryId: v.optional(v.id("categories")),
rootCategoryId: v.optional(v.id("categories")),
parentCategorySlug: v.optional(v.string()),
childCategorySlug: v.optional(v.string()),
topCategorySlug: v.optional(v.string()),
petCategorySlug: v.optional(v.string()),
tag: v.optional(v.string()),
recentlyAdded: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const products = await getProductsInScope(ctx, args);
const aggregated = aggregateFilterOptions(products as any);
return aggregated;
},
});
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, { slug }) => {
const product = await ctx.db
.query("products")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.unique();
if (!product || product.status !== "active") return null;
return getProductWithRelations(ctx, product._id);
},
});
export const getById = internalQuery({
args: { id: v.id("products") },
handler: async (ctx, { id }) => {
return getProductWithRelations(ctx, id);
},
});
/**
* One-time migration: backfill parentCategorySlug, childCategorySlug, topCategorySlug
* from categories. Run once after deploying Phase 1 schema.
* Assumes every product's category has parentId set.
*/
export const backfillProductCategorySlugs = internalMutation({
args: {},
handler: async (ctx) => {
const products = await ctx.db.query("products").collect();
let patched = 0;
let skipped = 0;
for (const product of products) {
const category = await ctx.db.get(product.categoryId);
if (!category) {
skipped += 1;
continue;
}
if (!category.parentId) {
skipped += 1;
continue;
}
const parent = await ctx.db.get(category.parentId);
if (!parent) {
skipped += 1;
continue;
}
const parentCategorySlug = parent.slug;
const childCategorySlug = category.slug;
const topCategorySlug =
category.topCategorySlug !== undefined && category.topCategorySlug !== null
? category.topCategorySlug
: undefined;
await ctx.db.patch(product._id, {
parentCategorySlug,
childCategorySlug,
...(topCategorySlug !== undefined && { topCategorySlug }),
});
patched += 1;
}
return { patched, skipped };
},
});
const productStatusValidator = v.union(
v.literal("active"),
v.literal("draft"),
v.literal("archived"),
);
export const search = query({
args: {
query: v.string(),
status: v.optional(productStatusValidator),
categoryId: v.optional(v.id("categories")),
brand: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const trimmed = args.query.trim();
if (!trimmed) return [];
let searchFilter = (q: any) => {
if (args.status !== undefined) q = q.eq("status", args.status);
if (args.categoryId !== undefined)
q = q.eq("categoryId", args.categoryId);
if (args.brand !== undefined) q = q.eq("brand", args.brand);
return q.search("name", trimmed);
};
const limit = args.limit ?? 24;
const results = await ctx.db
.query("products")
.withSearchIndex("search_products", searchFilter)
.take(limit);
return enrichProducts(ctx, results as any);
},
});
export const searchTypeahead = query({
args: {
query: v.string(),
parentCategorySlug: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const trimmed = args.query.trim();
if (trimmed.length < 3) return [];
const limit = Math.min(args.limit ?? 8, 20);
const searchFilter = (q: any) => {
q = q.eq("status", "active");
if (args.parentCategorySlug !== undefined) {
q = q.eq("parentCategorySlug", args.parentCategorySlug);
}
return q.search("name", trimmed);
};
const results = await ctx.db
.query("products")
.withSearchIndex("search_products", searchFilter)
.take(limit);
return Promise.all(
results.map(async (product) => {
const firstImage = await ctx.db
.query("productImages")
.withIndex("by_product", (q) => q.eq("productId", product._id))
.first();
const variants = await ctx.db
.query("productVariants")
.withIndex("by_product_and_active", (q) =>
q.eq("productId", product._id).eq("isActive", true),
)
.collect();
const minPriceVariant =
variants.length > 0
? variants.reduce((min, v) => (v.price < min.price ? v : min))
: null;
return {
_id: product._id,
name: product.name,
slug: product.slug,
parentCategorySlug: product.parentCategorySlug,
childCategorySlug: product.childCategorySlug,
brand: product.brand,
imageUrl: firstImage?.url,
imageAlt: firstImage?.alt,
minPrice: minPriceVariant?.price ?? 0,
compareAtPrice: minPriceVariant?.compareAtPrice,
averageRating: product.averageRating,
reviewCount: product.reviewCount,
};
}),
);
},
});
export const create = mutation({
args: {
name: v.string(),
slug: v.string(),
description: v.optional(v.string()),
status: v.union(
v.literal("active"),
v.literal("draft"),
v.literal("archived"),
),
categoryId: v.id("categories"),
tags: v.array(v.string()),
},
handler: async (ctx, args) => {
await Users.requireAdmin(ctx);
const category = await ctx.db.get(args.categoryId) as Doc<"categories"> | null;
if (!category) throw new Error("Category not found");
const parentCategorySlug = category.parentId
? (await ctx.db.get(category.parentId) as Doc<"categories"> | null)?.slug ?? category.slug
: category.slug;
const childCategorySlug = category.slug;
const topCategorySlug = category.topCategorySlug ?? undefined;
return await ctx.db.insert("products", {
...args,
parentCategorySlug,
childCategorySlug,
...(topCategorySlug !== undefined && { topCategorySlug }),
});
},
});
export const update = mutation({
args: {
id: v.id("products"),
name: v.optional(v.string()),
slug: v.optional(v.string()),
description: v.optional(v.string()),
status: v.optional(
v.union(
v.literal("active"),
v.literal("draft"),
v.literal("archived"),
),
),
categoryId: v.optional(v.id("categories")),
tags: v.optional(v.array(v.string())),
},
handler: async (ctx, { id, ...updates }) => {
await Users.requireAdmin(ctx);
const existing = await ctx.db.get(id);
if (!existing) throw new Error("Product not found");
const fields: Record<string, any> = {};
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) fields[key] = value;
}
const categoryId = fields.categoryId ?? existing.categoryId;
const category = await ctx.db.get(categoryId) as Doc<"categories"> | null;
if (category) {
const parentCategorySlug = category.parentId
? (await ctx.db.get(category.parentId) as Doc<"categories"> | null)?.slug ?? category.slug
: category.slug;
fields.parentCategorySlug = parentCategorySlug;
fields.childCategorySlug = category.slug;
if (category.topCategorySlug !== undefined && category.topCategorySlug !== null) {
fields.topCategorySlug = category.topCategorySlug;
}
}
await ctx.db.patch(id, fields);
return id;
},
});
export const archive = mutation({
args: { id: v.id("products") },
handler: async (ctx, { id }) => {
await Users.requireAdmin(ctx);
const existing = await ctx.db.get(id);
if (!existing) throw new Error("Product not found");
await ctx.db.patch(id, { status: "archived" });
return id;
},
});
// ─── Product images ───────────────────────────────────────────────────────
export const addImage = mutation({
args: {
productId: v.id("products"),
url: v.string(),
alt: v.optional(v.string()),
position: v.number(),
},
handler: async (ctx, args) => {
await Users.requireAdmin(ctx);
const product = await ctx.db.get(args.productId);
if (!product) throw new Error("Product not found");
return await ctx.db.insert("productImages", {
productId: args.productId,
url: args.url,
alt: args.alt,
position: args.position,
});
},
});
export const deleteImage = mutation({
args: { id: v.id("productImages") },
handler: async (ctx, { id }) => {
await Users.requireAdmin(ctx);
const image = await ctx.db.get(id);
if (!image) throw new Error("Image not found");
await ctx.db.delete(id);
return id;
},
});
export const reorderImages = mutation({
args: {
updates: v.array(
v.object({
id: v.id("productImages"),
position: v.number(),
}),
),
},
handler: async (ctx, { updates }) => {
await Users.requireAdmin(ctx);
for (const { id, position } of updates) {
const image = await ctx.db.get(id);
if (!image) throw new Error(`Image not found: ${id}`);
await ctx.db.patch(id, { position });
}
},
});
// ─── Product variants ──────────────────────────────────────────────────────
const variantAttributesValidator = v.optional(
v.object({
size: v.optional(v.string()),
flavor: v.optional(v.string()),
color: v.optional(v.string()),
}),
);
export const addVariant = mutation({
args: {
productId: v.id("products"),
name: v.string(),
sku: v.string(),
price: v.number(),
compareAtPrice: v.optional(v.number()),
stockQuantity: v.number(),
attributes: variantAttributesValidator,
isActive: v.boolean(),
weight: v.optional(v.number()),
weightUnit: v.optional(
v.union(
v.literal("g"),
v.literal("kg"),
v.literal("lb"),
v.literal("oz"),
),
),
},
handler: async (ctx, args) => {
await Users.requireAdmin(ctx);
const product = await ctx.db.get(args.productId);
if (!product) throw new Error("Product not found");
return await ctx.db.insert("productVariants", {
productId: args.productId,
name: args.name,
sku: args.sku,
price: args.price,
compareAtPrice: args.compareAtPrice,
stockQuantity: args.stockQuantity,
attributes: args.attributes,
isActive: args.isActive,
weight: args.weight ?? 0,
weightUnit: args.weightUnit ?? "g",
});
},
});
export const updateVariant = mutation({
args: {
id: v.id("productVariants"),
price: v.optional(v.number()),
compareAtPrice: v.optional(v.number()),
stockQuantity: v.optional(v.number()),
isActive: v.optional(v.boolean()),
},
handler: async (ctx, { id, ...updates }) => {
await Users.requireAdmin(ctx);
const variant = await ctx.db.get(id);
if (!variant) throw new Error("Variant not found");
const fields: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) fields[key] = value;
}
if (Object.keys(fields).length > 0) {
await ctx.db.patch(id, fields);
}
return id;
},
});
export const deleteVariant = mutation({
args: { id: v.id("productVariants") },
handler: async (ctx, { id }) => {
await Users.requireAdmin(ctx);
const variant = await ctx.db.get(id);
if (!variant) throw new Error("Variant not found");
const orderItemsWithVariant = await ctx.db
.query("orderItems")
.filter((q) => q.eq(q.field("variantId"), id))
.collect();
if (orderItemsWithVariant.length > 0) {
await ctx.db.patch(id, { isActive: false });
} else {
await ctx.db.delete(id);
}
return id;
},
});

291
convex/reviews.test.ts Normal file
View File

@@ -0,0 +1,291 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
import type { Doc } from "./_generated/dataModel";
const modules = import.meta.glob("./**/*.ts");
async function setupAdminUser(t: ReturnType<typeof convexTest>) {
const asAdmin = t.withIdentity({
name: "Admin",
email: "admin@example.com",
subject: "clerk_admin_123",
});
const userId = await asAdmin.mutation(api.users.store, {});
await t.run(async (ctx) => {
await ctx.db.patch(userId, { role: "admin" });
});
return asAdmin;
}
async function setupCategory(t: ReturnType<typeof convexTest>) {
let categoryId: import("./_generated/dataModel").Id<"categories">;
await t.run(async (ctx) => {
categoryId = await ctx.db.insert("categories", {
name: "Pet Food",
slug: "pet-food",
});
});
return categoryId!;
}
async function setupProduct(
t: ReturnType<typeof convexTest>,
asAdmin: ReturnType<ReturnType<typeof convexTest>["withIdentity"]>,
) {
const categoryId = await setupCategory(t);
return await asAdmin.mutation(api.products.create, {
name: "Review Product",
slug: "review-product",
status: "active",
categoryId,
tags: [],
});
}
describe("reviews", () => {
it("create adds review with isApproved false and duplicate throws", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId = await setupProduct(t, asAdmin);
const asCustomer = t.withIdentity({
name: "Customer",
email: "customer@example.com",
subject: "clerk_customer_123",
});
await asCustomer.mutation(api.users.store, {});
const reviewId = await asCustomer.mutation(api.reviews.create, {
productId,
rating: 5,
title: "Great product",
content: "Really liked it.",
});
expect(reviewId).toBeTruthy();
const byProduct = await t.query(api.reviews.listByProduct, {
productId,
limit: 10,
offset: 0,
});
expect(byProduct.page).toHaveLength(0);
await expect(
asCustomer.mutation(api.reviews.create, {
productId,
rating: 4,
title: "Second",
content: "Duplicate.",
}),
).rejects.toThrow(/already reviewed/);
});
it("approve updates product averageRating and reviewCount", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId = await setupProduct(t, asAdmin);
const asCustomer = t.withIdentity({
name: "Customer",
email: "customer@example.com",
subject: "clerk_customer_123",
});
await asCustomer.mutation(api.users.store, {});
const reviewId = await asCustomer.mutation(api.reviews.create, {
productId,
rating: 5,
title: "Great",
content: "Content here.",
});
const productBefore = (await t.run(async (ctx) => ctx.db.get(productId))) as Doc<"products"> | null;
expect(productBefore?.averageRating).toBeUndefined();
expect(productBefore?.reviewCount).toBeUndefined();
await asAdmin.mutation(api.reviews.approve, { id: reviewId });
const productAfter = (await t.run(async (ctx) => ctx.db.get(productId))) as Doc<"products"> | null;
expect(productAfter?.averageRating).toBe(5);
expect(productAfter?.reviewCount).toBe(1);
const byProduct = await t.query(api.reviews.listByProduct, {
productId,
limit: 10,
offset: 0,
});
expect(byProduct.page).toHaveLength(1);
expect(byProduct.page[0].rating).toBe(5);
});
it("create throws when rating out of range", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId = await setupProduct(t, asAdmin);
const asCustomer = t.withIdentity({
name: "Customer",
email: "customer@example.com",
subject: "clerk_customer_123",
});
await asCustomer.mutation(api.users.store, {});
await expect(
asCustomer.mutation(api.reviews.create, {
productId,
rating: 0,
title: "Bad",
content: "Content.",
}),
).rejects.toThrow(/1 and 5/);
await expect(
asCustomer.mutation(api.reviews.create, {
productId,
rating: 6,
title: "Bad",
content: "Content.",
}),
).rejects.toThrow(/1 and 5/);
});
it("listForAdmin and approve and deleteReview require admin", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId = await setupProduct(t, asAdmin);
const asCustomer = t.withIdentity({
name: "Customer",
email: "customer@example.com",
subject: "clerk_customer_123",
});
await asCustomer.mutation(api.users.store, {});
const reviewId = await asCustomer.mutation(api.reviews.create, {
productId,
rating: 3,
title: "OK",
content: "Content.",
});
await expect(
asCustomer.query(api.reviews.listForAdmin, { limit: 10, offset: 0 }),
).rejects.toThrow(/admin|Unauthorized/);
await expect(
asCustomer.mutation(api.reviews.approve, { id: reviewId }),
).rejects.toThrow(/admin|Unauthorized/);
await expect(
asCustomer.mutation(api.reviews.deleteReview, { id: reviewId }),
).rejects.toThrow(/admin|Unauthorized/);
await asAdmin.mutation(api.reviews.approve, { id: reviewId });
const adminList = await asAdmin.query(api.reviews.listForAdmin, {
limit: 10,
offset: 0,
});
expect(adminList.page.length).toBeGreaterThanOrEqual(1);
});
it("listByProductSorted sorts correctly", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId = await setupProduct(t, asAdmin);
const asCustomer1 = t.withIdentity({
name: "Customer 1",
email: "customer1@example.com",
subject: "clerk_c1",
});
await asCustomer1.mutation(api.users.store, {});
const r1 = await asCustomer1.mutation(api.reviews.create, {
productId,
rating: 3,
title: "C1",
content: "C1",
});
const asCustomer2 = t.withIdentity({
name: "Customer 2",
email: "customer2@example.com",
subject: "clerk_c2",
});
await asCustomer2.mutation(api.users.store, {});
const r2 = await asCustomer2.mutation(api.reviews.create, {
productId,
rating: 5,
title: "C2",
content: "C2",
});
await asAdmin.mutation(api.reviews.approve, { id: r1 });
await asAdmin.mutation(api.reviews.approve, { id: r2 });
const highest = await t.query(api.reviews.listByProductSorted, {
productId,
sortBy: "highest",
});
expect(highest.page.length).toBe(2);
expect(highest.page[0].rating).toBe(5);
const lowest = await t.query(api.reviews.listByProductSorted, {
productId,
sortBy: "lowest",
});
expect(lowest.page[0].rating).toBe(3);
});
it("hasUserReviewed works", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId = await setupProduct(t, asAdmin);
const asCustomer = t.withIdentity({
name: "Customer",
email: "cust@example.com",
subject: "clerk_c_has",
});
await asCustomer.mutation(api.users.store, {});
const hasBefore = await asCustomer.query(api.reviews.hasUserReviewed, { productId });
expect(hasBefore).toBe(false);
await asCustomer.mutation(api.reviews.create, {
productId,
rating: 4,
title: "Has",
content: "Has content",
});
const hasAfter = await asCustomer.query(api.reviews.hasUserReviewed, { productId });
expect(hasAfter).toBe(true);
});
it("submitAndRecalculate works", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId = await setupProduct(t, asAdmin);
const asCustomer = t.withIdentity({
name: "Customer",
email: "cust_submit@example.com",
subject: "clerk_c_submit",
});
await asCustomer.mutation(api.users.store, {});
const reviewId = await asCustomer.action(api.reviews.submitAndRecalculate, {
productId,
rating: 4,
title: "Title",
content: "Content",
});
expect(reviewId).toBeTruthy();
const product = (await t.run(async (ctx) => ctx.db.get(productId))) as Doc<"products"> | null;
// Since isApproved is false initially, the stats are actually undefined (or unchanged)
expect(product?.averageRating).toBeUndefined();
expect(product?.reviewCount).toBeUndefined();
});
});

269
convex/reviews.ts Normal file
View File

@@ -0,0 +1,269 @@
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;
},
});

291
convex/schema.ts Normal file
View File

@@ -0,0 +1,291 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
// ─── Customers ─────────────────────────────────────────────────────────
users: defineTable({
externalId: v.string(),
email: v.string(),
name: v.string(),
firstName: v.optional(v.string()),
lastName: v.optional(v.string()),
role: v.union(
v.literal("customer"),
v.literal("admin"),
v.literal("super_admin"),
),
avatarUrl: v.optional(v.string()),
phone: v.optional(v.string()),
stripeCustomerId: v.optional(v.string()),
createdAt: v.optional(v.number()),
lastLoginAt: v.optional(v.number()),
})
.index("by_external_id", ["externalId"])
.index("by_email", ["email"])
.index("by_role", ["role"]),
addresses: defineTable({
userId: v.id("users"),
type: v.union(v.literal("shipping"), v.literal("billing")),
fullName: v.string(),
firstName: v.string(),
lastName: v.string(),
phone: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
isDefault: v.boolean(),
isValidated: v.optional(v.boolean()),
})
.index("by_user", ["userId"])
.index("by_user_and_type", ["userId", "type"]),
// ─── Catalog (Categories & Products) ────────────────────────────────────
categories: defineTable({
name: v.string(),
slug: v.string(),
description: v.optional(v.string()),
imageUrl: v.optional(v.string()),
parentId: v.optional(v.id("categories")),
topCategorySlug: v.optional(v.string()),
seoTitle: v.optional(v.string()),
seoDescription: v.optional(v.string()),
})
.index("by_slug", ["slug"])
.index("by_parent", ["parentId"])
.index("by_parent_slug", ["parentId", "slug"])
.index("by_top_category_slug", ["topCategorySlug"]),
products: defineTable({
name: v.string(),
slug: v.string(),
description: v.optional(v.string()),
shortDescription: v.optional(v.string()),
status: v.union(
v.literal("active"),
v.literal("draft"),
v.literal("archived"),
),
categoryId: v.id("categories"),
brand: v.optional(v.string()),
tags: v.array(v.string()),
attributes: v.optional(
v.object({
petSize: v.optional(v.array(v.string())),
ageRange: v.optional(v.array(v.string())),
specialDiet: v.optional(v.array(v.string())),
material: v.optional(v.string()),
flavor: v.optional(v.string()),
}),
),
seoTitle: v.optional(v.string()),
seoDescription: v.optional(v.string()),
canonicalSlug: v.optional(v.string()),
averageRating: v.optional(v.number()),
reviewCount: v.optional(v.number()),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
parentCategorySlug: v.string(),
childCategorySlug: v.string(),
topCategorySlug: v.optional(v.string()),
})
.index("by_slug", ["slug"])
.index("by_status", ["status"])
.index("by_category", ["categoryId"])
.index("by_status_and_category", ["status", "categoryId"])
.index("by_brand", ["brand"])
.index("by_status_and_parent_slug", ["status", "parentCategorySlug"])
.index("by_status_and_parent_and_child_slug", [
"status",
"parentCategorySlug",
"childCategorySlug",
])
.index("by_status_and_top_category_slug", ["status", "topCategorySlug"])
.index("by_status_and_top_and_parent_slug", [
"status",
"topCategorySlug",
"parentCategorySlug",
])
.searchIndex("search_products", {
searchField: "name",
filterFields: ["status", "categoryId", "brand", "parentCategorySlug"],
}),
productImages: defineTable({
productId: v.id("products"),
url: v.string(),
alt: v.optional(v.string()),
position: v.number(),
}).index("by_product", ["productId"]),
productVariants: defineTable({
productId: v.id("products"),
name: v.string(),
sku: v.string(),
price: v.number(),
compareAtPrice: v.optional(v.number()),
stockQuantity: v.number(),
attributes: v.optional(
v.object({
size: v.optional(v.string()),
flavor: v.optional(v.string()),
color: v.optional(v.string()),
}),
),
isActive: v.boolean(),
weight: v.number(),
weightUnit: v.union(
v.literal("g"),
v.literal("kg"),
v.literal("lb"),
v.literal("oz"),
),
length: v.optional(v.number()),
width: v.optional(v.number()),
height: v.optional(v.number()),
dimensionUnit: v.optional(v.union(
v.literal("cm"),
v.literal("in"),
)),
})
.index("by_product", ["productId"])
.index("by_sku", ["sku"])
.index("by_product_and_active", ["productId", "isActive"]),
// ─── Orders & Payments ──────────────────────────────────────────────────
orders: defineTable({
orderNumber: v.string(),
userId: v.id("users"),
email: v.string(),
status: v.union(
v.literal("pending"),
v.literal("confirmed"),
v.literal("processing"),
v.literal("shipped"),
v.literal("delivered"),
v.literal("cancelled"),
v.literal("refunded"),
),
paymentStatus: v.union(
v.literal("pending"),
v.literal("paid"),
v.literal("failed"),
v.literal("refunded"),
),
subtotal: v.number(),
tax: v.number(),
shipping: v.number(),
discount: v.number(),
total: v.number(),
currency: v.string(),
shippingAddressSnapshot: v.object({
fullName: v.string(),
firstName: v.string(),
lastName: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
phone: v.optional(v.string()),
}),
billingAddressSnapshot: v.object({
firstName: v.string(),
lastName: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
}),
stripePaymentIntentId: v.optional(v.string()),
stripeCheckoutSessionId: v.optional(v.string()),
shippoOrderId: v.optional(v.string()),
shippoShipmentId: v.string(),
shippingMethod: v.string(),
shippingServiceCode: v.string(),
carrier: v.string(),
trackingNumber: v.optional(v.string()),
trackingUrl: v.optional(v.string()),
estimatedDelivery: v.optional(v.number()),
actualDelivery: v.optional(v.number()),
notes: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
paidAt: v.optional(v.number()),
shippedAt: v.optional(v.number()),
})
.index("by_user", ["userId"])
.index("by_status", ["status"])
.index("by_payment_status", ["paymentStatus"])
.index("by_order_number", ["orderNumber"])
.index("by_email", ["email"])
.index("by_created_at", ["createdAt"])
.index("by_stripe_checkout_session_id", ["stripeCheckoutSessionId"]),
orderItems: defineTable({
orderId: v.id("orders"),
variantId: v.id("productVariants"),
productName: v.string(),
variantName: v.string(),
sku: v.string(),
quantity: v.number(),
unitPrice: v.number(),
totalPrice: v.number(),
imageUrl: v.optional(v.string()),
}).index("by_order", ["orderId"]),
// ─── Reviews ───────────────────────────────────────────────────────────
reviews: defineTable({
productId: v.id("products"),
userId: v.id("users"),
orderId: v.optional(v.id("orders")),
rating: v.number(),
title: v.string(),
content: v.string(),
images: v.optional(v.array(v.string())),
verifiedPurchase: v.boolean(),
helpfulCount: v.number(),
createdAt: v.number(),
updatedAt: v.optional(v.number()),
isApproved: v.boolean(),
})
.index("by_product", ["productId"])
.index("by_user", ["userId"])
.index("by_product_approved", ["productId", "isApproved"]),
// ─── Wishlists ──────────────────────────────────────────────────────────
wishlists: defineTable({
userId: v.id("users"),
productId: v.id("products"),
variantId: v.optional(v.id("productVariants")),
addedAt: v.number(),
notifyOnPriceDrop: v.boolean(),
notifyOnBackInStock: v.boolean(),
priceWhenAdded: v.number(),
})
.index("by_user", ["userId"])
.index("by_product", ["productId"])
.index("by_user_and_product", ["userId", "productId"]),
// ─── Carts ──────────────────────────────────────────────────────────────
carts: defineTable({
userId: v.optional(v.id("users")),
sessionId: v.optional(v.string()),
items: v.array(
v.object({
productId: v.id("products"),
variantId: v.optional(v.id("productVariants")),
quantity: v.number(),
price: v.number(),
}),
),
createdAt: v.number(),
updatedAt: v.number(),
expiresAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_session", ["sessionId"]),
});

View File

@@ -0,0 +1,490 @@
import { convexTest } from "convex-test";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
import type { Id } from "./_generated/dataModel";
const { mockCustomersCreate, mockSessionsCreate, mockSessionsRetrieve } =
vi.hoisted(() => ({
mockCustomersCreate: vi.fn(),
mockSessionsCreate: vi.fn(),
mockSessionsRetrieve: vi.fn(),
}));
vi.mock("stripe", () => {
return {
default: class Stripe {
customers = { create: mockCustomersCreate };
checkout = {
sessions: {
create: mockSessionsCreate,
retrieve: mockSessionsRetrieve,
},
};
},
};
});
const modules = import.meta.glob("./**/*.ts");
const identity = {
name: "Alice Smith",
email: "alice@example.com",
subject: "clerk_alice_123",
};
const baseAddress = {
firstName: "Alice",
lastName: "Smith",
phone: "+447911123456",
addressLine1: "10 Downing Street",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
};
const shippingRate = {
provider: "DPD UK",
serviceName: "Next Day",
serviceToken: "dpd_uk_next_day",
amount: 5.5,
currency: "GBP",
estimatedDays: 1,
durationTerms: "1-2 business days",
carrierAccount: "ca_dpd_001",
};
beforeEach(() => {
vi.stubEnv("STRIPE_SECRET_KEY", "sk_test_fake_key");
vi.stubEnv("STOREFRONT_URL", "http://localhost:3000");
mockCustomersCreate.mockResolvedValue({ id: "cus_new_123" });
mockSessionsCreate.mockResolvedValue({
id: "cs_test_session_123",
client_secret: "cs_test_secret_abc",
status: "open",
payment_status: "unpaid",
});
mockSessionsRetrieve.mockResolvedValue({
id: "cs_test_session_123",
status: "complete",
payment_status: "paid",
customer_details: { email: "alice@example.com" },
});
});
afterEach(() => {
vi.unstubAllEnvs();
mockCustomersCreate.mockReset();
mockSessionsCreate.mockReset();
mockSessionsRetrieve.mockReset();
});
async function setupFullCheckoutContext(
t: ReturnType<typeof convexTest>,
overrides?: {
stockQuantity?: number;
price?: number;
isActive?: boolean;
stripeCustomerId?: string;
},
) {
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
if (overrides?.stripeCustomerId) {
await t.run(async (ctx) => {
await ctx.db.patch(userId, {
stripeCustomerId: overrides.stripeCustomerId,
});
});
}
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
additionalInformation: "Flat 2",
isDefault: true,
isValidated: true,
});
let productId: Id<"products">;
let variantId: Id<"productVariants">;
await t.run(async (ctx) => {
const categoryId = await ctx.db.insert("categories", {
name: "Dog Food",
slug: "dog-food",
});
productId = await ctx.db.insert("products", {
name: "Premium Kibble",
slug: "premium-kibble",
status: "active",
categoryId,
tags: [],
parentCategorySlug: "dogs",
childCategorySlug: "dog-food",
});
variantId = await ctx.db.insert("productVariants", {
productId,
name: "1kg Bag",
sku: "PK-001",
price: overrides?.price ?? 24.99,
stockQuantity: overrides?.stockQuantity ?? 50,
isActive: overrides?.isActive ?? true,
weight: 1000,
weightUnit: "g",
length: 30,
width: 20,
height: 10,
dimensionUnit: "cm",
});
await ctx.db.insert("productImages", {
productId,
url: "https://example.com/kibble.jpg",
position: 0,
});
await ctx.db.insert("carts", {
userId,
items: [
{
productId,
variantId,
quantity: 2,
price: overrides?.price ?? 24.99,
},
],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
return {
userId,
addressId,
productId: productId!,
variantId: variantId!,
asA,
};
}
// ─── createCheckoutSession ───────────────────────────────────────────────────
describe("stripeActions.createCheckoutSession", () => {
it("throws when user is not authenticated", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await expect(
t.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
}),
).rejects.toThrow(/signed in/i);
});
it("throws when cart is empty", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const addressId = await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await expect(
asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
}),
).rejects.toThrow(/cart is empty/i);
});
it("throws when cart has blocking issues (out of stock)", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t, {
stockQuantity: 0,
});
await expect(
asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
}),
).rejects.toThrow(/cart has issues/i);
});
it("creates a Stripe customer when user has none and patches the user", async () => {
const t = convexTest(schema, modules);
const { userId, addressId, asA } = await setupFullCheckoutContext(t);
mockCustomersCreate.mockResolvedValue({ id: "cus_fresh_456" });
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
expect(mockCustomersCreate).toHaveBeenCalledOnce();
expect(mockCustomersCreate).toHaveBeenCalledWith({
email: "alice@example.com",
name: "Alice Smith",
metadata: { convexUserId: userId },
});
const updatedUser = await t.run(async (ctx) => ctx.db.get(userId));
expect(updatedUser?.stripeCustomerId).toBe("cus_fresh_456");
});
it("reuses existing Stripe customer and does not create a new one", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t, {
stripeCustomerId: "cus_existing_789",
});
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
expect(mockCustomersCreate).not.toHaveBeenCalled();
});
it("returns clientSecret on success", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
const result = await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
expect(result).toEqual({ clientSecret: "cs_test_secret_abc" });
});
it("builds correct line items from cart", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
const createCall = mockSessionsCreate.mock.calls[0][0];
expect(createCall.line_items).toHaveLength(1);
expect(createCall.line_items[0]).toEqual({
price_data: {
currency: "gbp",
product_data: { name: "Premium Kibble — 1kg Bag" },
unit_amount: Math.round(24.99 * 100),
},
quantity: 2,
});
});
it("passes correct shipping options with amount in pence", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
const createCall = mockSessionsCreate.mock.calls[0][0];
expect(createCall.shipping_options).toHaveLength(1);
const rateData = createCall.shipping_options[0].shipping_rate_data;
expect(rateData.fixed_amount.amount).toBe(550);
expect(rateData.fixed_amount.currency).toBe("gbp");
expect(rateData.display_name).toBe("DPD UK — Next Day");
expect(rateData.delivery_estimate.minimum).toEqual({
unit: "business_day",
value: 1,
});
});
it("passes correct metadata including all shipping fields", async () => {
const t = convexTest(schema, modules);
const { userId, addressId, asA } = await setupFullCheckoutContext(t);
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
const createCall = mockSessionsCreate.mock.calls[0][0];
expect(createCall.metadata).toEqual({
convexUserId: userId,
addressId,
shipmentObjectId: "shp_test_123",
shippingMethod: "DPD UK — Next Day",
shippingServiceCode: "dpd_uk_next_day",
carrier: "DPD UK",
carrierAccount: "ca_dpd_001",
});
});
it("creates session with ui_mode custom and correct return_url", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
const createCall = mockSessionsCreate.mock.calls[0][0];
expect(createCall.mode).toBe("payment");
expect(createCall.ui_mode).toBe("custom");
expect(createCall.return_url).toBe(
"http://localhost:3000/checkout/success?session_id={CHECKOUT_SESSION_ID}",
);
});
it("passes Stripe customer ID to session creation", async () => {
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t, {
stripeCustomerId: "cus_existing_789",
});
await asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
});
const createCall = mockSessionsCreate.mock.calls[0][0];
expect(createCall.customer).toBe("cus_existing_789");
});
it("throws when STOREFRONT_URL env var is missing", async () => {
vi.stubEnv("STOREFRONT_URL", "");
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await expect(
asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
}),
).rejects.toThrow(/STOREFRONT_URL/i);
});
it("throws when Stripe returns no client_secret", async () => {
mockSessionsCreate.mockResolvedValue({
id: "cs_test_no_secret",
client_secret: null,
});
const t = convexTest(schema, modules);
const { addressId, asA } = await setupFullCheckoutContext(t);
await expect(
asA.action(api.stripeActions.createCheckoutSession, {
addressId,
shipmentObjectId: "shp_test_123",
shippingRate,
}),
).rejects.toThrow(/client_secret/i);
});
});
// ─── getCheckoutSessionStatus ────────────────────────────────────────────────
describe("stripeActions.getCheckoutSessionStatus", () => {
it("returns complete status with customer email", async () => {
mockSessionsRetrieve.mockResolvedValue({
status: "complete",
payment_status: "paid",
customer_details: { email: "alice@example.com" },
});
const t = convexTest(schema, modules);
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
sessionId: "cs_test_session_123",
});
expect(result).toEqual({
status: "complete",
paymentStatus: "paid",
customerEmail: "alice@example.com",
});
expect(mockSessionsRetrieve).toHaveBeenCalledWith("cs_test_session_123");
});
it("returns expired status", async () => {
mockSessionsRetrieve.mockResolvedValue({
status: "expired",
payment_status: "unpaid",
customer_details: null,
});
const t = convexTest(schema, modules);
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
sessionId: "cs_expired_456",
});
expect(result).toEqual({
status: "expired",
paymentStatus: "unpaid",
customerEmail: null,
});
});
it("returns open status when payment not completed", async () => {
mockSessionsRetrieve.mockResolvedValue({
status: "open",
payment_status: "unpaid",
customer_details: { email: "alice@example.com" },
});
const t = convexTest(schema, modules);
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
sessionId: "cs_open_789",
});
expect(result).toEqual({
status: "open",
paymentStatus: "unpaid",
customerEmail: "alice@example.com",
});
});
it("returns null email when customer_details is absent", async () => {
mockSessionsRetrieve.mockResolvedValue({
status: "complete",
payment_status: "paid",
customer_details: undefined,
});
const t = convexTest(schema, modules);
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
sessionId: "cs_no_email",
});
expect(result.customerEmail).toBeNull();
});
});

238
convex/stripeActions.ts Normal file
View File

@@ -0,0 +1,238 @@
"use node";
import Stripe from "stripe";
import { action, internalAction } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import { getOrCreateStripeCustomer } from "./model/stripe";
import type { Id } from "./_generated/dataModel";
import type {
CheckoutSessionResult,
CheckoutSessionStatus,
CartValidationResult,
} from "./model/checkout";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const createCheckoutSession = action({
args: {
addressId: v.id("addresses"),
shipmentObjectId: v.string(),
shippingRate: v.object({
provider: v.string(),
serviceName: v.string(),
serviceToken: v.string(),
amount: v.number(),
currency: v.string(),
estimatedDays: v.union(v.number(), v.null()),
durationTerms: v.string(),
carrierAccount: v.string(),
}),
sessionId: v.optional(v.string()),
},
handler: async (ctx, args): Promise<CheckoutSessionResult> => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("You must be signed in to checkout.");
}
const userId: Id<"users"> = await ctx.runQuery(
internal.checkout.getCurrentUserId,
);
const user = await ctx.runQuery(internal.users.getById, { userId });
const stripeCustomerId = await getOrCreateStripeCustomer({
stripeCustomerId: user.stripeCustomerId,
email: user.email,
name: user.name,
convexUserId: userId,
});
if (!user.stripeCustomerId) {
await ctx.runMutation(internal.users.setStripeCustomerId, {
userId,
stripeCustomerId,
});
}
const address = await ctx.runQuery(internal.checkout.getAddressById, {
addressId: args.addressId,
});
if (address.userId !== userId) {
throw new Error("Address does not belong to the current user.");
}
const cartResult: CartValidationResult | null = await ctx.runQuery(
internal.checkout.validateCartInternal,
{ userId, sessionId: args.sessionId },
);
if (!cartResult) {
throw new Error("Your cart is empty.");
}
if (!cartResult.valid) {
throw new Error("Your cart has issues that need to be resolved.");
}
const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] =
cartResult.items.map((item) => ({
price_data: {
currency: "gbp",
product_data: {
name: `${item.productName}${item.variantName}`,
},
unit_amount: Math.round(item.unitPrice),
},
quantity: item.quantity,
}));
const STOREFRONT_URL = process.env.STOREFRONT_URL;
if (!STOREFRONT_URL) {
throw new Error("STOREFRONT_URL environment variable is not set.");
}
const session: Stripe.Checkout.Session =
await stripe.checkout.sessions.create({
mode: "payment",
ui_mode: "custom",
customer: stripeCustomerId,
line_items: lineItems,
shipping_options: [
{
shipping_rate_data: {
type: "fixed_amount",
fixed_amount: {
amount: Math.round(args.shippingRate.amount * 100),
currency: "gbp",
},
display_name: `${args.shippingRate.provider}${args.shippingRate.serviceName}`,
...(args.shippingRate.estimatedDays != null && {
delivery_estimate: {
minimum: {
unit: "business_day" as const,
value: args.shippingRate.estimatedDays,
},
maximum: {
unit: "business_day" as const,
value: args.shippingRate.estimatedDays,
},
},
}),
},
},
],
return_url: `${STOREFRONT_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
metadata: {
convexUserId: userId,
addressId: args.addressId,
shipmentObjectId: args.shipmentObjectId,
shippingMethod: `${args.shippingRate.provider}${args.shippingRate.serviceName}`,
shippingServiceCode: args.shippingRate.serviceToken,
carrier: args.shippingRate.provider,
carrierAccount: args.shippingRate.carrierAccount,
},
});
if (!session.client_secret) {
throw new Error("Stripe session missing client_secret.");
}
return { clientSecret: session.client_secret };
},
});
export const getCheckoutSessionStatus = action({
args: {
sessionId: v.string(),
},
handler: async (_ctx, args): Promise<CheckoutSessionStatus> => {
const session: Stripe.Checkout.Session =
await stripe.checkout.sessions.retrieve(args.sessionId);
return {
status: session.status as "complete" | "expired" | "open",
paymentStatus: session.payment_status,
customerEmail: session.customer_details?.email ?? null,
};
},
});
export const handleWebhook = internalAction({
args: {
payload: v.string(),
signature: v.string(),
},
handler: async (ctx, args): Promise<{ success: boolean }> => {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error("STRIPE_WEBHOOK_SECRET environment variable is not set.");
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
args.payload,
args.signature,
webhookSecret,
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return { success: false };
}
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded": {
const session = event.data.object as Stripe.Checkout.Session;
const metadata = session.metadata;
if (
!metadata?.convexUserId ||
!metadata?.addressId ||
!metadata?.shipmentObjectId ||
!metadata?.shippingMethod ||
!metadata?.shippingServiceCode ||
!metadata?.carrier
) {
console.error(
"Missing required metadata on checkout session:",
session.id,
);
return { success: false };
}
await ctx.runMutation(internal.orders.fulfillFromCheckout, {
stripeCheckoutSessionId: session.id,
stripePaymentIntentId:
typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent?.id ?? null,
convexUserId: metadata.convexUserId,
addressId: metadata.addressId,
shipmentObjectId: metadata.shipmentObjectId,
shippingMethod: metadata.shippingMethod,
shippingServiceCode: metadata.shippingServiceCode,
carrier: metadata.carrier,
amountTotal: session.amount_total,
amountShipping:
session.shipping_cost?.amount_total ??
session.total_details?.amount_shipping ??
0,
currency: session.currency,
});
break;
}
case "checkout.session.expired":
console.warn(
"Checkout session expired:",
(event.data.object as Stripe.Checkout.Session).id,
);
break;
default:
console.log("Unhandled Stripe event type:", event.type);
}
return { success: true };
},
});

134
convex/users.test.ts Normal file
View File

@@ -0,0 +1,134 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
describe("users", () => {
it("stores a new user on first login", async () => {
const t = convexTest(schema, modules);
const asSarah = t.withIdentity({
name: "Sarah",
email: "sarah@example.com",
subject: "clerk_sarah_123",
});
const userId = await asSarah.mutation(api.users.store, {});
expect(userId).toBeTruthy();
const user = await asSarah.query(api.users.current, {});
expect(user).toMatchObject({
name: "Sarah",
email: "sarah@example.com",
role: "customer",
});
});
it("returns same user ID on subsequent logins", async () => {
const t = convexTest(schema, modules);
const asSarah = t.withIdentity({
name: "Sarah",
email: "sarah@example.com",
subject: "clerk_sarah_123",
});
const id1 = await asSarah.mutation(api.users.store, {});
const id2 = await asSarah.mutation(api.users.store, {});
expect(id1).toEqual(id2);
});
it("updates name if it changed", async () => {
const t = convexTest(schema, modules);
const asSarah = t.withIdentity({
name: "Sarah",
email: "sarah@example.com",
subject: "clerk_sarah_123",
});
await asSarah.mutation(api.users.store, {});
const asSarahRenamed = t.withIdentity({
name: "Sarah Connor",
email: "sarah@example.com",
subject: "clerk_sarah_123",
});
await asSarahRenamed.mutation(api.users.store, {});
const user = await asSarahRenamed.query(api.users.current, {});
expect(user?.name).toBe("Sarah Connor");
});
it("returns null for unauthenticated user", async () => {
const t = convexTest(schema, modules);
const result = await t.query(api.users.current, {});
expect(result).toBeNull();
});
it("returns user for authenticated user", async () => {
const t = convexTest(schema, modules);
const asSarah = t.withIdentity({
name: "Sarah",
email: "sarah@example.com",
subject: "clerk_sarah_123",
});
await asSarah.mutation(api.users.store, {});
const user = await asSarah.query(api.users.current, {});
expect(user).not.toBeNull();
expect(user?.email).toBe("sarah@example.com");
});
it("updateProfile updates only name, phone, avatarUrl for current user", async () => {
const t = convexTest(schema, modules);
const asSarah = t.withIdentity({
name: "Sarah",
email: "sarah@example.com",
subject: "clerk_sarah_123",
});
await asSarah.mutation(api.users.store, {});
await asSarah.mutation(api.users.updateProfile, {
name: "Sarah Jane",
phone: "555-0000",
avatarUrl: "https://example.com/avatar.png",
});
const user = await asSarah.query(api.users.current, {});
expect(user?.name).toBe("Sarah Jane");
expect(user?.phone).toBe("555-0000");
expect(user?.avatarUrl).toBe("https://example.com/avatar.png");
expect(user?.email).toBe("sarah@example.com");
});
it("listCustomers returns only customers and is admin-only", async () => {
const t = convexTest(schema, modules);
const asCustomer = t.withIdentity({
name: "Customer",
email: "customer@example.com",
subject: "clerk_customer_789",
});
const asAdmin = t.withIdentity({
name: "Admin",
email: "admin@example.com",
subject: "clerk_admin_456",
});
await asCustomer.mutation(api.users.store, {});
const adminId = await asAdmin.mutation(api.users.store, {});
await t.run(async (ctx) => {
await ctx.db.patch(adminId, { role: "admin" });
});
await expect(
asCustomer.query(api.users.listCustomers, {
paginationOpts: { numItems: 10, cursor: null },
}),
).rejects.toThrow(/Unauthorized|admin/);
const result = await asAdmin.query(api.users.listCustomers, {
paginationOpts: { numItems: 10, cursor: null },
});
expect(result.page.length).toBeGreaterThanOrEqual(1);
expect(result.page.every((u: { role: string }) => u.role === "customer")).toBe(
true,
);
});
});

140
convex/users.ts Normal file
View File

@@ -0,0 +1,140 @@
import {
mutation,
query,
internalMutation,
internalQuery,
} from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import * as Users from "./model/users";
export const store = mutation({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
const existing = await ctx.db
.query("users")
.withIndex("by_external_id", (q) =>
q.eq("externalId", identity.subject),
)
.unique();
if (existing) {
if (existing.name !== identity.name) {
await ctx.db.patch(existing._id, {
name: identity.name ?? existing.name,
});
}
return existing._id;
}
return await ctx.db.insert("users", {
name: identity.name ?? "Anonymous",
email: identity.email ?? "",
role: "customer",
externalId: identity.subject,
avatarUrl: identity.pictureUrl ?? undefined,
});
},
});
export const current = query({
args: {},
handler: async (ctx) => Users.getCurrentUser(ctx),
});
export const updateProfile = mutation({
args: {
name: v.optional(v.string()),
phone: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const patch: { name?: string; phone?: string; avatarUrl?: string } = {};
if (args.name !== undefined) patch.name = args.name;
if (args.phone !== undefined) patch.phone = args.phone;
if (args.avatarUrl !== undefined) patch.avatarUrl = args.avatarUrl;
if (Object.keys(patch).length === 0) return user._id;
await ctx.db.patch(user._id, patch);
return user._id;
},
});
export const listCustomers = query({
args: {
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
await Users.requireAdmin(ctx);
return await ctx.db
.query("users")
.withIndex("by_role", (q) => q.eq("role", "customer"))
.order("desc")
.paginate(args.paginationOpts);
},
});
export const upsertFromClerk = internalMutation({
args: {
externalId: v.string(),
name: v.string(),
email: v.string(),
avatarUrl: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("users")
.withIndex("by_external_id", (q) =>
q.eq("externalId", args.externalId),
)
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
name: args.name,
email: args.email,
avatarUrl: args.avatarUrl,
});
} else {
await ctx.db.insert("users", {
...args,
role: "customer",
});
}
},
});
export const deleteFromClerk = internalMutation({
args: { externalId: v.string() },
handler: async (ctx, { externalId }) => {
const user = await ctx.db
.query("users")
.withIndex("by_external_id", (q) => q.eq("externalId", externalId))
.unique();
if (user) await ctx.db.delete(user._id);
},
});
export const getById = internalQuery({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user) throw new Error("User not found");
return user;
},
});
export const setStripeCustomerId = internalMutation({
args: {
userId: v.id("users"),
stripeCustomerId: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
stripeCustomerId: args.stripeCustomerId,
});
},
});

166
convex/wishlists.test.ts Normal file
View File

@@ -0,0 +1,166 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
async function setupAdminUser(t: ReturnType<typeof convexTest>) {
const asAdmin = t.withIdentity({
name: "Admin",
email: "admin@example.com",
subject: "clerk_admin_123",
});
const userId = await asAdmin.mutation(api.users.store, {});
await t.run(async (ctx) => {
await ctx.db.patch(userId, { role: "admin" });
});
return asAdmin;
}
async function setupCategory(t: ReturnType<typeof convexTest>) {
let categoryId: import("./_generated/dataModel").Id<"categories">;
await t.run(async (ctx) => {
categoryId = await ctx.db.insert("categories", {
name: "Pet Food",
slug: "pet-food",
});
});
return categoryId!;
}
async function setupProduct(
t: ReturnType<typeof convexTest>,
asAdmin: ReturnType<ReturnType<typeof convexTest>["withIdentity"]>,
) {
const categoryId = await setupCategory(t);
return await asAdmin.mutation(api.products.create, {
name: "Wishlist Product",
slug: "wishlist-product",
status: "active",
categoryId,
tags: [],
});
}
describe("wishlists", () => {
it("toggle adds then removes and list is empty after remove", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId = await setupProduct(t, asAdmin);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
const addResult = await asA.mutation(api.wishlists.toggle, { productId });
expect(addResult).toEqual({ added: true, id: expect.anything() });
const listAfterAdd = await asA.query(api.wishlists.list, {});
expect(listAfterAdd).toHaveLength(1);
const removeResult = await asA.mutation(api.wishlists.toggle, { productId });
expect(removeResult).toEqual({ removed: true });
const listAfterRemove = await asA.query(api.wishlists.list, {});
expect(listAfterRemove).toHaveLength(0);
});
it("isWishlisted is true after add and false after remove", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId = await setupProduct(t, asAdmin);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
expect(await asA.query(api.wishlists.isWishlisted, { productId })).toBe(
false,
);
await asA.mutation(api.wishlists.toggle, { productId });
expect(await asA.query(api.wishlists.isWishlisted, { productId })).toBe(
true,
);
await asA.mutation(api.wishlists.toggle, { productId });
expect(await asA.query(api.wishlists.isWishlisted, { productId })).toBe(
false,
);
});
it("count returns 0 for unauthenticated user", async () => {
const t = convexTest(schema, modules);
const count = await t.query(api.wishlists.count, {});
expect(count).toBe(0);
});
it("count returns correct number after add and toggle", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId1 = await setupProduct(t, asAdmin);
const categoryId = await setupCategory(t);
const productId2 = await asAdmin.mutation(api.products.create, {
name: "Wishlist Product 2",
slug: "wishlist-product-2",
status: "active",
categoryId,
tags: [],
});
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
expect(await asA.query(api.wishlists.count, {})).toBe(0);
await asA.mutation(api.wishlists.toggle, { productId: productId1 });
await asA.mutation(api.wishlists.toggle, { productId: productId2 });
expect(await asA.query(api.wishlists.count, {})).toBe(2);
await asA.mutation(api.wishlists.toggle, { productId: productId1 });
expect(await asA.query(api.wishlists.count, {})).toBe(1);
});
it("remove throws if user does not own the wishlist item", async () => {
const t = convexTest(schema, modules);
const asAdmin = await setupAdminUser(t);
const productId = await setupProduct(t, asAdmin);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
const asB = t.withIdentity({
name: "Bob",
email: "bob@example.com",
subject: "clerk_bob_456",
});
await asA.mutation(api.users.store, {});
await asB.mutation(api.users.store, {});
await asA.mutation(api.wishlists.add, { productId });
const listA = await asA.query(api.wishlists.list, {});
expect(listA).toHaveLength(1);
const wishlistId = listA[0]._id;
await expect(
asB.mutation(api.wishlists.remove, { id: wishlistId }),
).rejects.toThrow(/Unauthorized|does not belong/);
await asA.mutation(api.wishlists.remove, { id: wishlistId });
const listAfter = await asA.query(api.wishlists.list, {});
expect(listAfter).toHaveLength(0);
});
});

183
convex/wishlists.ts Normal file
View File

@@ -0,0 +1,183 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import * as Users from "./model/users";
import { enrichProducts } from "./model/products";
import type { Id } from "./_generated/dataModel";
export const list = query({
args: {},
handler: async (ctx) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const rows = await ctx.db
.query("wishlists")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.collect();
if (rows.length === 0) return [];
const productIds = [...new Set(rows.map((r) => r.productId))];
const products = (
await Promise.all(productIds.map((id) => ctx.db.get(id)))
).filter(Boolean) as Awaited<ReturnType<typeof ctx.db.get>>[];
const enriched = await enrichProducts(ctx, products);
const productMap = new Map(
enriched.map((p) => [p._id, p]),
);
return rows.map((row) => {
const product = productMap.get(row.productId);
const variant = row.variantId && product?.variants
? product.variants.find((v: { _id: Id<"productVariants"> }) => v._id === row.variantId)
: undefined;
return { ...row, product, variant };
});
},
});
function findExistingEntry(
rows: { variantId?: Id<"productVariants"> }[],
variantId?: Id<"productVariants">,
) {
return rows.find((r) => {
if (variantId === undefined && r.variantId === undefined) return true;
return r.variantId === variantId;
});
}
export const add = mutation({
args: {
productId: v.id("products"),
variantId: v.optional(v.id("productVariants")),
notifyOnPriceDrop: v.optional(v.boolean()),
notifyOnBackInStock: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const existing = await ctx.db
.query("wishlists")
.withIndex("by_user_and_product", (q) =>
q.eq("userId", user._id).eq("productId", args.productId),
)
.collect();
const found = findExistingEntry(existing, args.variantId);
if (found)
return { id: (found as { _id: Id<"wishlists"> })._id, alreadyExisted: true };
let priceWhenAdded = 0;
if (args.variantId) {
const variant = await ctx.db.get(args.variantId);
if (variant && variant.productId === args.productId) {
priceWhenAdded = variant.price;
}
} else {
const variants = await ctx.db
.query("productVariants")
.withIndex("by_product_and_active", (q) =>
q.eq("productId", args.productId).eq("isActive", true),
)
.first();
if (variants) priceWhenAdded = variants.price;
}
const id = await ctx.db.insert("wishlists", {
userId: user._id,
productId: args.productId,
variantId: args.variantId,
addedAt: Date.now(),
notifyOnPriceDrop: args.notifyOnPriceDrop ?? false,
notifyOnBackInStock: args.notifyOnBackInStock ?? false,
priceWhenAdded,
});
return { id, alreadyExisted: false };
},
});
export const remove = mutation({
args: { id: v.id("wishlists") },
handler: async (ctx, { id }) => {
const doc = await ctx.db.get(id);
if (!doc) throw new Error("Wishlist item not found");
await Users.requireOwnership(ctx, doc.userId);
await ctx.db.delete(id);
},
});
export const toggle = mutation({
args: {
productId: v.id("products"),
variantId: v.optional(v.id("productVariants")),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const existing = await ctx.db
.query("wishlists")
.withIndex("by_user_and_product", (q) =>
q.eq("userId", user._id).eq("productId", args.productId),
)
.collect();
const found = findExistingEntry(existing, args.variantId) as
| { _id: Id<"wishlists"> }
| undefined;
if (found) {
await ctx.db.delete(found._id);
return { removed: true };
}
const id = await ctx.db.insert("wishlists", {
userId: user._id,
productId: args.productId,
variantId: args.variantId,
addedAt: Date.now(),
notifyOnPriceDrop: false,
notifyOnBackInStock: false,
priceWhenAdded: await (async () => {
if (args.variantId) {
const v = await ctx.db.get(args.variantId);
return v && v.productId === args.productId ? v.price : 0;
}
const first = await ctx.db
.query("productVariants")
.withIndex("by_product_and_active", (q) =>
q.eq("productId", args.productId).eq("isActive", true),
)
.first();
return first?.price ?? 0;
})(),
});
return { added: true, id };
},
});
export const count = query({
args: {},
handler: async (ctx) => {
const user = await Users.getCurrentUser(ctx);
if (!user) return 0;
const rows = await ctx.db
.query("wishlists")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.collect();
return rows.length;
},
});
export const isWishlisted = query({
args: {
productId: v.id("products"),
variantId: v.optional(v.id("productVariants")),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const rows = await ctx.db
.query("wishlists")
.withIndex("by_user_and_product", (q) =>
q.eq("userId", user._id).eq("productId", args.productId),
)
.collect();
return !!findExistingEntry(rows, args.variantId);
},
});