- 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.
443 lines
12 KiB
TypeScript
443 lines
12 KiB
TypeScript
import { ConvexError } from "convex/values";
|
|
import type {
|
|
AddressValidationResult,
|
|
RecommendedAddress,
|
|
ShippoRate,
|
|
ValidatedCartItem,
|
|
} from "./checkout";
|
|
|
|
type ValidateAddressInput = {
|
|
addressLine1: string;
|
|
additionalInformation?: string;
|
|
city: string;
|
|
postalCode: string;
|
|
country: string;
|
|
name?: string;
|
|
};
|
|
|
|
type ShippoRawResponse = {
|
|
original_address: {
|
|
address_line_1: string;
|
|
address_line_2?: string;
|
|
city_locality: string;
|
|
state_province: string;
|
|
postal_code: string;
|
|
country_code: string;
|
|
name?: string;
|
|
organization?: string;
|
|
};
|
|
recommended_address?: {
|
|
address_line_1: string;
|
|
address_line_2?: string;
|
|
city_locality: string;
|
|
state_province: string;
|
|
postal_code: string;
|
|
country_code: string;
|
|
complete_address?: string;
|
|
confidence_result: {
|
|
score: "high" | "medium" | "low";
|
|
code: string;
|
|
description: string;
|
|
};
|
|
};
|
|
analysis: {
|
|
validation_result: {
|
|
value: "valid" | "partially_valid" | "invalid";
|
|
reasons: Array<{ code: string; description: string }>;
|
|
};
|
|
address_type:
|
|
| "residential"
|
|
| "commercial"
|
|
| "unknown"
|
|
| "po_box"
|
|
| "military";
|
|
changed_attributes?: string[];
|
|
};
|
|
geo?: {
|
|
latitude: number;
|
|
longitude: number;
|
|
};
|
|
};
|
|
|
|
const SHIPPO_VALIDATE_URL =
|
|
"https://api.goshippo.com/v2/addresses/validate";
|
|
|
|
/**
|
|
* Calls Shippo Address Validation v2 and normalizes the response into
|
|
* an `AddressValidationResult`. This is a pure async helper — it does NOT
|
|
* export a Convex function; it's consumed by actions in `checkoutActions.ts`.
|
|
*/
|
|
export async function validateAddressWithShippo(
|
|
input: ValidateAddressInput,
|
|
): Promise<AddressValidationResult> {
|
|
const apiKey = process.env.SHIPPO_API_KEY;
|
|
if (!apiKey) {
|
|
throw new ConvexError(
|
|
"Address validation is unavailable (missing API key configuration).",
|
|
);
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
params.set("address_line_1", input.addressLine1);
|
|
if (input.additionalInformation)
|
|
params.set("address_line_2", input.additionalInformation);
|
|
params.set("city_locality", input.city);
|
|
params.set("postal_code", input.postalCode);
|
|
params.set("country_code", input.country);
|
|
if (input.name) params.set("name", input.name);
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`${SHIPPO_VALIDATE_URL}?${params.toString()}`, {
|
|
method: "GET",
|
|
headers: { Authorization: `ShippoToken ${apiKey}` },
|
|
});
|
|
} catch (err) {
|
|
throw new ConvexError(
|
|
"Address validation service is unreachable. Please try again later.",
|
|
);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new ConvexError(
|
|
`Address validation service unavailable (status ${response.status}).`,
|
|
);
|
|
}
|
|
|
|
let body: ShippoRawResponse;
|
|
try {
|
|
body = (await response.json()) as ShippoRawResponse;
|
|
} catch {
|
|
throw new ConvexError(
|
|
"Address validation returned an unexpected response. Please try again.",
|
|
);
|
|
}
|
|
|
|
if (!body.analysis?.validation_result) {
|
|
throw new ConvexError(
|
|
"Address validation returned a malformed response.",
|
|
);
|
|
}
|
|
|
|
const { analysis, recommended_address, original_address } = body;
|
|
|
|
let recommendedAddress: RecommendedAddress | undefined;
|
|
if (recommended_address) {
|
|
recommendedAddress = {
|
|
addressLine1: recommended_address.address_line_1,
|
|
additionalInformation: recommended_address.address_line_2,
|
|
city: recommended_address.city_locality,
|
|
postalCode: recommended_address.postal_code,
|
|
country: recommended_address.country_code,
|
|
completeAddress: recommended_address.complete_address,
|
|
confidenceScore: recommended_address.confidence_result.score,
|
|
confidenceCode: recommended_address.confidence_result.code,
|
|
confidenceDescription:
|
|
recommended_address.confidence_result.description,
|
|
};
|
|
}
|
|
|
|
return {
|
|
isValid: analysis.validation_result.value === "valid",
|
|
validationValue: analysis.validation_result.value,
|
|
reasons: analysis.validation_result.reasons.map((r) => ({
|
|
code: r.code,
|
|
description: r.description,
|
|
})),
|
|
addressType: analysis.address_type,
|
|
changedAttributes: analysis.changed_attributes ?? [],
|
|
recommendedAddress,
|
|
originalAddress: {
|
|
addressLine1: original_address.address_line_1,
|
|
additionalInformation: original_address.address_line_2,
|
|
city: original_address.city_locality,
|
|
postalCode: original_address.postal_code,
|
|
country: original_address.country_code,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── Shipping Rates Helpers ──────────────────────────────────────────────────
|
|
|
|
export const PREFERRED_CARRIERS = ["DPD UK", "Evri UK", "UPS", "UDS"];
|
|
|
|
/**
|
|
* Hard ceiling across preferred UK carriers.
|
|
* DPD UK premium (door-to-door, Saturday/Sunday): 30kg
|
|
* DPD UK standard (Classic, Next Day, Two Day): 20kg
|
|
* Evri UK (Courier Collection, ParcelShop): 15kg
|
|
*/
|
|
export const MAX_PARCEL_WEIGHT_G = 30_000;
|
|
|
|
const WEIGHT_TO_GRAMS: Record<ValidatedCartItem["weightUnit"], number> = {
|
|
g: 1,
|
|
kg: 1000,
|
|
lb: 453.592,
|
|
oz: 28.3495,
|
|
};
|
|
|
|
const DIMENSION_TO_CM: Record<NonNullable<ValidatedCartItem["dimensionUnit"]>, number> = {
|
|
cm: 1,
|
|
in: 2.54,
|
|
};
|
|
|
|
type ParcelResult = {
|
|
weight: string;
|
|
mass_unit: "g";
|
|
length?: string;
|
|
width?: string;
|
|
height?: string;
|
|
distance_unit?: "cm";
|
|
};
|
|
|
|
export function computeParcel(items: ValidatedCartItem[]): ParcelResult {
|
|
let totalWeightGrams = 0;
|
|
for (const item of items) {
|
|
const factor = WEIGHT_TO_GRAMS[item.weightUnit];
|
|
totalWeightGrams += item.weight * factor * item.quantity;
|
|
}
|
|
|
|
const withDimensions = items.filter(
|
|
(item): item is ValidatedCartItem & { length: number; width: number; height: number; dimensionUnit: "cm" | "in" } =>
|
|
item.length != null && item.width != null && item.height != null && item.dimensionUnit != null,
|
|
);
|
|
|
|
if (withDimensions.length === 0) {
|
|
return { weight: String(Math.round(totalWeightGrams)), mass_unit: "g" };
|
|
}
|
|
|
|
let maxLengthCm = 0;
|
|
let maxWidthCm = 0;
|
|
let totalHeightCm = 0;
|
|
|
|
for (const item of withDimensions) {
|
|
const factor = DIMENSION_TO_CM[item.dimensionUnit];
|
|
const lengthCm = item.length * factor;
|
|
const widthCm = item.width * factor;
|
|
const heightCm = item.height * factor * item.quantity;
|
|
|
|
if (lengthCm > maxLengthCm) maxLengthCm = lengthCm;
|
|
if (widthCm > maxWidthCm) maxWidthCm = widthCm;
|
|
totalHeightCm += heightCm;
|
|
}
|
|
|
|
return {
|
|
weight: String(Math.round(totalWeightGrams)),
|
|
mass_unit: "g",
|
|
length: String(Math.round(maxLengthCm * 100) / 100),
|
|
width: String(Math.round(maxWidthCm * 100) / 100),
|
|
height: String(Math.round(totalHeightCm * 100) / 100),
|
|
distance_unit: "cm",
|
|
};
|
|
}
|
|
|
|
const SHIPPO_SHIPMENTS_URL = "https://api.goshippo.com/shipments/";
|
|
|
|
export async function getShippingRatesFromShippo(input: {
|
|
sourceAddressId: string;
|
|
destinationAddress: {
|
|
name: string;
|
|
street1: string;
|
|
street2?: string;
|
|
city: string;
|
|
zip: string;
|
|
country: string;
|
|
phone?: string;
|
|
};
|
|
parcels: Array<{
|
|
weight: string;
|
|
mass_unit: string;
|
|
length?: string;
|
|
width?: string;
|
|
height?: string;
|
|
distance_unit?: string;
|
|
}>;
|
|
}): Promise<{ shipmentObjectId: string; rates: ShippoRate[] }> {
|
|
const apiKey = process.env.SHIPPO_API_KEY;
|
|
if (!apiKey) {
|
|
throw new ConvexError(
|
|
"Shipping rate service is unavailable (missing API key configuration).",
|
|
);
|
|
}
|
|
|
|
const requestBody = {
|
|
address_from: input.sourceAddressId,
|
|
address_to: input.destinationAddress,
|
|
parcels: input.parcels,
|
|
async: false,
|
|
};
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(SHIPPO_SHIPMENTS_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `ShippoToken ${apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
} catch {
|
|
throw new ConvexError(
|
|
"Shipping rate service is unreachable. Please try again later.",
|
|
);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let errorDetail = "";
|
|
try {
|
|
const errBody = await response.json();
|
|
errorDetail = JSON.stringify(errBody);
|
|
console.error("Shippo /shipments/ error:", response.status, errorDetail);
|
|
} catch {
|
|
console.error("Shippo /shipments/ error:", response.status, "(no parseable body)");
|
|
}
|
|
throw new ConvexError(
|
|
`Shipping rate service unavailable (status ${response.status}).`,
|
|
);
|
|
}
|
|
|
|
let body: {
|
|
object_id: string;
|
|
messages?: Array<{ source: string; text: string }>;
|
|
rates: Array<{
|
|
object_id: string;
|
|
provider: string;
|
|
servicelevel: { name: string; token: string };
|
|
amount: string;
|
|
currency: string;
|
|
estimated_days: number;
|
|
duration_terms: string;
|
|
arrives_by?: string | null;
|
|
carrier_account: string;
|
|
}>;
|
|
};
|
|
try {
|
|
body = await response.json();
|
|
} catch {
|
|
throw new ConvexError(
|
|
"Shipping rate service returned an unexpected response. Please try again.",
|
|
);
|
|
}
|
|
|
|
if (body.rates.length === 0 && body.messages?.length) {
|
|
console.warn(
|
|
"Shippo returned 0 rates. Carrier messages:",
|
|
body.messages.map((m) => `[${m.source}] ${m.text}`).join(" | "),
|
|
);
|
|
}
|
|
|
|
const rates: ShippoRate[] = body.rates.map((rate) => ({
|
|
objectId: rate.object_id,
|
|
provider: rate.provider,
|
|
servicelevelName: rate.servicelevel.name,
|
|
servicelevelToken: rate.servicelevel.token,
|
|
amount: rate.amount,
|
|
currency: rate.currency,
|
|
estimatedDays: rate.estimated_days,
|
|
durationTerms: rate.duration_terms,
|
|
arrivesBy: rate.arrives_by ?? null,
|
|
carrierAccount: rate.carrier_account,
|
|
}));
|
|
|
|
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[];
|
|
} {
|
|
if (rates.length === 0) {
|
|
throw new ConvexError(
|
|
"No shipping rates available for this address. Please verify your address and try again.",
|
|
);
|
|
}
|
|
|
|
const preferredLower = PREFERRED_CARRIERS.map((c) => c.toLowerCase());
|
|
|
|
const preferred = rates.filter((r) =>
|
|
preferredLower.includes(r.provider.toLowerCase()),
|
|
);
|
|
|
|
const sortByDaysThenPrice = (a: ShippoRate, b: ShippoRate) => {
|
|
const aDays = a.estimatedDays ?? Infinity;
|
|
const bDays = b.estimatedDays ?? Infinity;
|
|
const daysDiff = aDays - bDays;
|
|
if (daysDiff !== 0) return daysDiff;
|
|
return parseFloat(a.amount) - parseFloat(b.amount);
|
|
};
|
|
|
|
if (preferred.length > 0) {
|
|
preferred.sort(sortByDaysThenPrice);
|
|
return { selected: preferred[0], alternatives: preferred.slice(1, 3) };
|
|
}
|
|
|
|
console.warn(
|
|
"No preferred carriers returned rates. Falling back to all carriers.",
|
|
);
|
|
const sorted = [...rates].sort(sortByDaysThenPrice);
|
|
return { selected: sorted[0], alternatives: sorted.slice(1, 3) };
|
|
}
|