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>
548 lines
16 KiB
TypeScript
548 lines
16 KiB
TypeScript
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;
|
|
},
|
|
});
|