Files
the-pet-loft/convex/orders.ts
ianshaloom cc15338ad9 feat: initial commit — storefront, convex backend, and shared packages
Completes the first milestone of The Pet Loft ecommerce platform:
- apps/storefront: full customer-facing Next.js app with HeroUI (cart,
  checkout, orders, wishlist, product detail, shop, search, auth)
- convex/: serverless backend with schema, queries, mutations, actions,
  HTTP routes, Stripe/Shippo integrations, and co-located tests
- packages/types, packages/utils, packages/convex: shared workspace packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:31:18 +03:00

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;
},
});