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:
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;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user