feat(convex): add implementation rules and configuration for backend functions
- Introduced a comprehensive markdown document outlining implementation rules for Convex functions, including syntax, registration, HTTP endpoints, and TypeScript usage. - Created a new configuration file for the Convex app, integrating the Resend service. - Added a new HTTP route for handling Shippo webhooks to ensure proper response handling. - Implemented integration tests for order timeline events, covering various scenarios including order fulfillment and status changes. - Enhanced existing functions with type safety improvements and additional validation logic. This commit establishes clear guidelines for backend development and improves the overall structure and reliability of the Convex application.
This commit is contained in:
@@ -1,6 +1,24 @@
|
||||
import { QueryCtx } from "../_generated/server";
|
||||
import { QueryCtx, MutationCtx } from "../_generated/server";
|
||||
import { Id, Doc } from "../_generated/dataModel";
|
||||
|
||||
export async function recordOrderTimelineEvent(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
orderId: Id<"orders">;
|
||||
eventType: string;
|
||||
source: string;
|
||||
fromStatus?: string;
|
||||
toStatus?: string;
|
||||
payload?: string;
|
||||
userId?: Id<"users">;
|
||||
},
|
||||
): Promise<void> {
|
||||
await ctx.db.insert("orderTimelineEvents", {
|
||||
...args,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrderWithItems(
|
||||
ctx: QueryCtx,
|
||||
orderId: Id<"orders">,
|
||||
@@ -48,6 +66,37 @@ export function canCustomerCancel(order: Doc<"orders">): {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a customer is allowed to request a return for a given order.
|
||||
*
|
||||
* Eligibility: order must be `delivered` (customer has received the goods),
|
||||
* return not yet requested, and not already refunded.
|
||||
* Returns and cancellations are separate flows — cancellation is only available
|
||||
* on `confirmed` orders (before fulfilment begins).
|
||||
*/
|
||||
export function canCustomerRequestReturn(order: Doc<"orders">): {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
} {
|
||||
if (order.status !== "delivered") {
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
"Returns are only available for delivered orders.",
|
||||
};
|
||||
}
|
||||
if (order.returnRequestedAt) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "A return has already been requested for this order.",
|
||||
};
|
||||
}
|
||||
if (order.paymentStatus === "refunded") {
|
||||
return { allowed: false, reason: "This order has already been refunded." };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export interface OutOfStockItem {
|
||||
variantId: Id<"productVariants">;
|
||||
requested: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryCtx, MutationCtx } from "../_generated/server";
|
||||
import { Id } from "../_generated/dataModel";
|
||||
import type { Id, Doc } from "../_generated/dataModel";
|
||||
|
||||
/**
|
||||
* Recalculate product averageRating and reviewCount from approved reviews.
|
||||
@@ -57,10 +57,10 @@ export async function getProductWithRelations(
|
||||
|
||||
export async function enrichProducts(
|
||||
ctx: QueryCtx,
|
||||
products: Awaited<ReturnType<typeof ctx.db.query>>[],
|
||||
products: Doc<"products">[],
|
||||
) {
|
||||
return Promise.all(
|
||||
products.map(async (product: any) => {
|
||||
products.map(async (product) => {
|
||||
const [imagesRaw, variants] = await Promise.all([
|
||||
ctx.db
|
||||
.query("productImages")
|
||||
|
||||
@@ -343,6 +343,68 @@ export async function getShippingRatesFromShippo(input: {
|
||||
return { shipmentObjectId: body.object_id, rates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a Shippo shipment by ID and returns the `object_id` of the rate
|
||||
* matching `serviceCode` (servicelevel.token) and `carrier` (provider).
|
||||
* Throws a ConvexError if the shipment is not found, the rate is missing,
|
||||
* or the API is unreachable.
|
||||
*/
|
||||
export async function getShipmentRateObjectId(
|
||||
shipmentId: string,
|
||||
serviceCode: string,
|
||||
carrier: string,
|
||||
): Promise<string> {
|
||||
const apiKey = process.env.SHIPPO_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new ConvexError("Shipping service unavailable (missing API key).");
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${SHIPPO_SHIPMENTS_URL}${shipmentId}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `ShippoToken ${apiKey}` },
|
||||
});
|
||||
} catch {
|
||||
throw new ConvexError("Shippo service is unreachable. Please try again.");
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new ConvexError(`Shipment "${shipmentId}" not found in Shippo.`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ConvexError(`Shippo service error (status ${response.status}).`);
|
||||
}
|
||||
|
||||
let body: {
|
||||
object_id: string;
|
||||
rates: Array<{
|
||||
object_id: string;
|
||||
provider: string;
|
||||
servicelevel: { token: string; name: string };
|
||||
}>;
|
||||
};
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ConvexError("Shippo returned an unexpected response.");
|
||||
}
|
||||
|
||||
const matching = body.rates.filter(
|
||||
(r) =>
|
||||
r.servicelevel.token === serviceCode &&
|
||||
r.provider.toLowerCase() === carrier.toLowerCase(),
|
||||
);
|
||||
|
||||
if (matching.length === 0) {
|
||||
throw new ConvexError(
|
||||
`No rate found for service "${serviceCode}" and carrier "${carrier}". The rate may have expired.`,
|
||||
);
|
||||
}
|
||||
|
||||
return matching[0].object_id;
|
||||
}
|
||||
|
||||
export function selectBestRate(rates: ShippoRate[]): {
|
||||
selected: ShippoRate;
|
||||
alternatives: ShippoRate[];
|
||||
|
||||
Reference in New Issue
Block a user