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:
337
convex/addresses.test.ts
Normal file
337
convex/addresses.test.ts
Normal 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
168
convex/addresses.ts
Normal 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
10
convex/auth.config.ts
Normal 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
121
convex/carts.test.ts
Normal 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
237
convex/carts.ts
Normal 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
124
convex/categories.test.ts
Normal 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
120
convex/categories.ts
Normal 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
447
convex/checkout.test.ts
Normal 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
92
convex/checkout.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
331
convex/checkoutActions.test.ts
Normal file
331
convex/checkoutActions.test.ts
Normal 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
136
convex/checkoutActions.ts
Normal 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
80
convex/http.ts
Normal 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
80
convex/model/carts.ts
Normal 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;
|
||||
}
|
||||
11
convex/model/categories.ts
Normal file
11
convex/model/categories.ts
Normal 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;
|
||||
}
|
||||
127
convex/model/checkout.test.ts
Normal file
127
convex/model/checkout.test.ts
Normal 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
257
convex/model/checkout.ts
Normal 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
85
convex/model/orders.ts
Normal 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
80
convex/model/products.ts
Normal 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
775
convex/model/shippo.test.ts
Normal 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
380
convex/model/shippo.ts
Normal 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
24
convex/model/stripe.ts
Normal 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
38
convex/model/users.ts
Normal 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
564
convex/orders.test.ts
Normal 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
547
convex/orders.ts
Normal 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
840
convex/products.test.ts
Normal 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
929
convex/products.ts
Normal 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
291
convex/reviews.test.ts
Normal 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
269
convex/reviews.ts
Normal 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
291
convex/schema.ts
Normal 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"]),
|
||||
});
|
||||
490
convex/stripeActions.test.ts
Normal file
490
convex/stripeActions.test.ts
Normal 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
238
convex/stripeActions.ts
Normal 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
134
convex/users.test.ts
Normal 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
140
convex/users.ts
Normal 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
166
convex/wishlists.test.ts
Normal 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
183
convex/wishlists.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user