Convex backend (AUDIT-5–10): - schema: add returnLabelUrl, returnTrackingNumber, returnCarrier fields + by_return_tracking_number_and_carrier and by_stripe_payment_intent_id indexes - orders: markReturnReceived now sets status="completed"; add getOrderByPaymentIntent and applyReturnAccepted internal helpers - returnActions: add acceptReturn action — creates Shippo return label (is_return:true), persists label data, sends return label email to customer - stripeActions: handle refund.updated webhook to auto-mark orders refunded via Stripe Dashboard - shippoWebhook: add getOrderByReturnTracking; applyTrackingUpdate extended with isReturnTracking flag (return events use return_tracking_update type, skip delivered transition) - emails: add sendReturnLabelEmail; fulfillmentActions: createShippingLabel action Admin UI (AUDIT-1–6): - OrderActionsBar: full rewrite per authoritative action matrix; remove UpdateStatusDialog; add AcceptReturnButton for delivered+returnRequested state - AcceptReturnButton: new action component matching CreateLabelButton pattern - FulfilmentCard: add returnLabelUrl prop; show "Return label" row; rename outbound label to "Outbound label" when both are present - statusConfig: add return_accepted to OrderEventType and EVENT_TYPE_LABELS - orders detail page and all supporting cards/components Storefront & shared (TS fixes): - checkout/success, CheckoutErrorState, OrderReviewStep, PaymentStep: replace Button as/color/isLoading/variant="flat" with HeroUI v3-compatible props - ReviewList: isLoading → isPending for HeroUI v3 Button - packages/utils: add return and completed entries to ORDER_STATUS_LABELS/COLORS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
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"),
|
|
v.literal("return"),
|
|
v.literal("completed"),
|
|
),
|
|
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()),
|
|
labelUrl: v.optional(v.string()),
|
|
trackingStatus: 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()),
|
|
returnRequestedAt: v.optional(v.number()),
|
|
returnReceivedAt: v.optional(v.number()),
|
|
returnLabelUrl: v.optional(v.string()),
|
|
returnTrackingNumber: v.optional(v.string()),
|
|
returnCarrier: v.optional(v.string()),
|
|
})
|
|
.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"])
|
|
.index("by_tracking_number_and_carrier", ["trackingNumber", "carrier"])
|
|
.index("by_return_tracking_number_and_carrier", ["returnTrackingNumber", "returnCarrier"])
|
|
.index("by_stripe_payment_intent_id", ["stripePaymentIntentId"]),
|
|
|
|
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"]),
|
|
|
|
orderTimelineEvents: defineTable({
|
|
orderId: v.id("orders"),
|
|
eventType: v.string(), // "status_change" | "customer_cancel" | "return_requested" | "return_received" | "refund" | "tracking_update" | "label_created"
|
|
source: v.string(), // "stripe_webhook" | "fulfillment" | "admin" | "shippo_webhook" | "customer_cancel" | "customer_return"
|
|
fromStatus: v.optional(v.string()),
|
|
toStatus: v.optional(v.string()),
|
|
payload: v.optional(v.string()), // JSON string for Shippo/Stripe payloads
|
|
createdAt: v.number(),
|
|
userId: v.optional(v.id("users")),
|
|
})
|
|
.index("by_order", ["orderId"])
|
|
.index("by_order_and_created_at", ["orderId", "createdAt"]),
|
|
|
|
// ─── 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"]),
|
|
});
|