Files
the-pet-loft/convex/schema.ts
ianshaloom 3d50cb895c feat(orders): implement QA audit fixes — return flow, refund webhook, TS cleanup
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>
2026-03-07 17:59:29 +03:00

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