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>
188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
import {
|
|
internalAction,
|
|
internalMutation,
|
|
internalQuery,
|
|
} from "./_generated/server";
|
|
import { internal } from "./_generated/api";
|
|
import { v } from "convex/values";
|
|
import { recordOrderTimelineEvent } from "./model/orders";
|
|
|
|
// ─── Internal queries ─────────────────────────────────────────────────────────
|
|
|
|
export const getOrderByTracking = internalQuery({
|
|
args: { trackingNumber: v.string(), carrier: v.string() },
|
|
handler: async (ctx, { trackingNumber, carrier }) => {
|
|
return await ctx.db
|
|
.query("orders")
|
|
.withIndex("by_tracking_number_and_carrier", (q) =>
|
|
q.eq("trackingNumber", trackingNumber).eq("carrier", carrier),
|
|
)
|
|
.first();
|
|
},
|
|
});
|
|
|
|
export const getOrderByReturnTracking = internalQuery({
|
|
args: { returnTrackingNumber: v.string(), returnCarrier: v.string() },
|
|
handler: async (ctx, { returnTrackingNumber, returnCarrier }) => {
|
|
return await ctx.db
|
|
.query("orders")
|
|
.withIndex("by_return_tracking_number_and_carrier", (q) =>
|
|
q.eq("returnTrackingNumber", returnTrackingNumber).eq("returnCarrier", returnCarrier),
|
|
)
|
|
.first();
|
|
},
|
|
});
|
|
|
|
// ─── Internal mutation ────────────────────────────────────────────────────────
|
|
|
|
export const applyTrackingUpdate = internalMutation({
|
|
args: {
|
|
orderId: v.id("orders"),
|
|
trackingStatus: v.string(),
|
|
estimatedDelivery: v.optional(v.number()),
|
|
isDelivered: v.boolean(),
|
|
isReturnTracking: v.boolean(),
|
|
payload: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const order = await ctx.db.get(args.orderId);
|
|
if (!order) return;
|
|
|
|
// Idempotency: skip if this exact status was already applied
|
|
if (order.trackingStatus === args.trackingStatus) {
|
|
console.log(
|
|
`[shippoWebhook] Skipping duplicate tracking status "${args.trackingStatus}" for order ${args.orderId}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
// Return tracking updates never set status to "delivered" — only outbound does
|
|
if (args.isDelivered && !args.isReturnTracking) {
|
|
await ctx.db.patch(args.orderId, {
|
|
trackingStatus: args.trackingStatus,
|
|
...(args.estimatedDelivery !== undefined
|
|
? { estimatedDelivery: args.estimatedDelivery }
|
|
: {}),
|
|
status: "delivered",
|
|
actualDelivery: now,
|
|
updatedAt: now,
|
|
});
|
|
} else {
|
|
await ctx.db.patch(args.orderId, {
|
|
trackingStatus: args.trackingStatus,
|
|
...(args.estimatedDelivery !== undefined
|
|
? { estimatedDelivery: args.estimatedDelivery }
|
|
: {}),
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
await recordOrderTimelineEvent(ctx, {
|
|
orderId: args.orderId,
|
|
eventType: args.isReturnTracking ? "return_tracking_update" : "tracking_update",
|
|
source: "shippo_webhook",
|
|
...(args.isDelivered && !args.isReturnTracking
|
|
? { fromStatus: order.status, toStatus: "delivered" }
|
|
: {}),
|
|
payload: args.payload,
|
|
});
|
|
|
|
if (args.isDelivered && !args.isReturnTracking) {
|
|
const customer = await ctx.db.get(order.userId);
|
|
if (customer) {
|
|
await ctx.scheduler.runAfter(0, internal.emails.sendDeliveryConfirmation, {
|
|
to: customer.email,
|
|
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
|
|
orderNumber: order.orderNumber,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
// ─── Internal action ──────────────────────────────────────────────────────────
|
|
|
|
type ShippoTrackUpdatedPayload = {
|
|
event?: string;
|
|
data?: {
|
|
carrier?: string;
|
|
tracking_number?: string;
|
|
eta?: string | null;
|
|
tracking_status?: {
|
|
status?: string;
|
|
status_details?: string;
|
|
status_date?: string;
|
|
};
|
|
};
|
|
};
|
|
|
|
export const handleTrackUpdated = internalAction({
|
|
args: { body: v.string() },
|
|
handler: async (ctx, { body }) => {
|
|
let payload: ShippoTrackUpdatedPayload;
|
|
try {
|
|
payload = JSON.parse(body) as ShippoTrackUpdatedPayload;
|
|
} catch {
|
|
console.error("[shippoWebhook] Failed to parse JSON body");
|
|
return;
|
|
}
|
|
|
|
if (payload.event !== "track_updated") {
|
|
console.log("[shippoWebhook] Ignoring event:", payload.event);
|
|
return;
|
|
}
|
|
|
|
const { data } = payload;
|
|
const trackingNumber = data?.tracking_number;
|
|
const carrier = data?.carrier;
|
|
|
|
if (!trackingNumber || !carrier) {
|
|
console.error(
|
|
"[shippoWebhook] Missing tracking_number or carrier in payload",
|
|
);
|
|
return;
|
|
}
|
|
|
|
let order = await ctx.runQuery(
|
|
internal.shippoWebhook.getOrderByTracking,
|
|
{ trackingNumber, carrier },
|
|
);
|
|
let isReturnTracking = false;
|
|
|
|
if (!order) {
|
|
order = await ctx.runQuery(
|
|
internal.shippoWebhook.getOrderByReturnTracking,
|
|
{ returnTrackingNumber: trackingNumber, returnCarrier: carrier },
|
|
);
|
|
isReturnTracking = !!order;
|
|
}
|
|
|
|
if (!order) {
|
|
console.log(
|
|
`[shippoWebhook] No order found for tracking ${trackingNumber} / ${carrier}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const trackingStatus = data?.tracking_status?.status ?? "UNKNOWN";
|
|
const isDelivered = trackingStatus === "DELIVERED";
|
|
|
|
const eta = data?.eta;
|
|
const estimatedDelivery =
|
|
eta && !isNaN(new Date(eta).getTime())
|
|
? new Date(eta).getTime()
|
|
: undefined;
|
|
|
|
await ctx.runMutation(internal.shippoWebhook.applyTrackingUpdate, {
|
|
orderId: order._id,
|
|
trackingStatus,
|
|
estimatedDelivery,
|
|
isDelivered,
|
|
isReturnTracking,
|
|
payload: body,
|
|
});
|
|
},
|
|
});
|