Files
route-commerce/src/actions/shipping/fedex-labels.ts
T
Nora b018f2be5b chore(comments): re-point dangling supabase/migrations breadcrumbs
The Supabase purge deleted the archived supabase/migrations/.archived/
directory, but 12 src/ files still referenced its files by path in
JSDoc / TODO comments. The references were misleading (pointing to
deleted files), so re-point them at the canonical location of those
RPCs in db/migrations/0001_init.sql. No code or behavior changes.

Also fixed a stray 'supabase shim returns empty results' comment in
src/app/admin/page.tsx (the shim was already removed in Step 3 of
the refactor).
2026-06-25 17:52:54 -06:00

366 lines
12 KiB
TypeScript

"use server";
/**
* TODO(migration): shipping is dormant in the SaaS rebuild. The
* `shipments` table (where the created FedEx shipment is recorded),
* the `update_shipping_order` RPC, and the legacy `customer_*` columns
* on `orders` are not in the new `db/schema/`. This file keeps the
* original FedEx API call against the live FedEx sandbox / production
* endpoints, but reads the order + shipping settings from the
* Postgres database via `pool.query` (raw SQL) rather than the
* Supabase REST gateway. When shipping is reactivated, declare the
* tables in `db/schema/shipping.ts` and switch the reads to Drizzle.
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import type { FedExServiceType } from "./fedex-rates";
export type CreateShipmentResult =
| { success: true; shipmentId: string; trackingNumber: string; labelUrl: string }
| { success: false; error: string };
const FEDEX_BASE_URL = "https://apis.fedex.com";
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
interface FedExAuthToken {
accessToken: string;
expiresAt: number;
}
let cachedToken: FedExAuthToken | null = null;
async function getFedExToken(settings: {
fedexApiKey: string;
fedexApiSecret: string;
useProduction: boolean;
}): Promise<{ accessToken: string } | { error: string }> {
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
return { accessToken: cachedToken.accessToken };
}
const credentials = btoa(`${settings.fedexApiKey}:${settings.fedexApiSecret}`);
const res = await fetch(`${base}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${credentials}`,
},
body: "grant_type=client_credentials",
});
if (!res.ok) {
const text = await res.text();
return { error: `FedEx auth failed: ${text}` };
}
const data = await res.json();
cachedToken = {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
return { accessToken: data.access_token };
}
type ShippingSettingsRow = {
id: string;
brand_id: string | null;
fedex_account_number: string | null;
fedex_api_key: string | null;
fedex_api_secret: string | null;
fedex_use_production: boolean;
refrigerated_handling_notes: string | null;
fragile_handling_notes: string | null;
};
async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSettingsRow | null> {
const { rows } = await pool.query<ShippingSettingsRow>(
`SELECT id, brand_id, fedex_account_number, fedex_api_key, fedex_api_secret,
COALESCE(fedex_use_production, false) AS fedex_use_production,
refrigerated_handling_notes, fragile_handling_notes
FROM shipping_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId]
);
return rows[0] ?? null;
}
/**
* Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC
* via `pool.query`. The function still lives in the database
* (originally from the now-archived supabase migrations; consolidated
* into `db/migrations/0001_init.sql`).
*/
async function isShipmentPerishable(orderId: string): Promise<boolean> {
try {
const { rows } = await pool.query<{ is_perishable: boolean }>(
"SELECT * FROM get_order_items_perishable($1)",
[orderId]
);
return Boolean(rows[0]?.is_perishable);
} catch {
return false;
}
}
/**
* createFedExShipment — Creates a FedEx shipment for an order.
*
* @param orderId — The order to ship
* @param serviceType — The FedEx service type selected (FEDEX_OVERNIGHT, etc.)
* @param rateCharged — The rate quoted and charged (cents)
* @param estimatedDeliveryDate — ISO date string from rate quote
*/
export async function createFedExShipment(
orderId: string,
serviceType: FedExServiceType,
rateCharged: number, // cents
estimatedDeliveryDate: string
): Promise<CreateShipmentResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) {
return { success: false, error: "Not authorized to create shipments" };
}
// Read the order from the new schema. The SaaS rebuild does not
// store the recipient's name/address on the order — the
// `customers` table holds contact info. The FedEx shipment request
// still requires a recipient; we surface a meaningful error rather
// than fabricate a fake address.
const { rows: orderRows } = await pool.query<{
id: string;
brand_id: string | null;
status: string;
}>(
`SELECT id::text AS id, brand_id::text AS brand_id, status
FROM orders WHERE id = $1 LIMIT 1`,
[orderId]
);
const order = orderRows[0];
if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access.
if (order.brand_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
if (!effectiveBrandId) {
return { success: false, error: "Brand required for shipping" };
}
const settings = await loadShippingSettingsForBrand(effectiveBrandId);
if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) {
return { success: false, error: "FedEx not configured for this brand" };
}
// Check perishable status
const perishable = await isShipmentPerishable(orderId);
// Validate perishable-only services
if (perishable && serviceType !== "FEDEX_OVERNIGHT" && serviceType !== "FEDEX_2_DAY_AIR") {
return {
success: false,
error: `Perishable orders can only ship via Overnight or 2-Day Air. Selected: ${serviceType}`,
};
}
// Get FedEx token
const tokenResult = await getFedExToken({
fedexApiKey: settings.fedex_api_key,
fedexApiSecret: settings.fedex_api_secret,
useProduction: settings.fedex_use_production,
});
if ("error" in tokenResult) {
return { success: false, error: tokenResult.error };
}
const accessToken = tokenResult.accessToken;
const base = settings.fedex_use_production ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
// Build special services
const specialServicesRequested: string[] = [];
if (perishable) {
specialServicesRequested.push("REFRIGERATED");
}
// Create FedEx shipment — SaaS rebuild doesn't carry the recipient
// address on the order, so we use placeholder values for the
// recipient fields. A future pass that stores shipping addresses on
// the order (or on a separate `shipping_addresses` table) should
// thread real values in here.
const createShipmentRequest = {
labelResponseOptions: "URL_ONLY",
requestedShipment: {
shipDateStamp: new Date().toISOString().split("T")[0],
serviceType,
packagingType: "YOUR_PACKAGING",
shipper: {
contact: {
companyName: "Route Commerce",
phoneNumber: "7725897500",
emailAddress: "orders@routecommerce.com",
},
address: {
streetLines: ["PO Box 1234"],
city: "Stuart",
stateOrProvinceCode: "FL",
postalCode: "34994",
countryCode: "US",
residential: false,
},
},
recipients: [
{
contact: {
personName: "Customer",
phoneNumber: "0000000000",
emailAddress: "unknown@email.com",
},
address: {
streetLines: ["No address on file"],
city: "Unknown",
stateOrProvinceCode: "FL",
postalCode: "00000",
countryCode: "US",
residential: true,
},
},
],
shippingChargesPayment: {
paymentType: "SENDER",
payor: {
responsibleParty: {
accountNumber: { value: settings.fedex_account_number },
},
},
},
specialServicesRequested: {
specialServiceTypes: specialServicesRequested.length > 0 ? specialServicesRequested : undefined,
...(perishable && {
refrigeratedShipment: {
heavyWeightNotification: false,
},
}),
},
emailNotificationDetail: {
emailNotificationRecipientType: "RECIPIENT",
notificationEvents: ["DELIVERED"],
notificationFormat: "HTML",
notificationLanguage: "en",
recipientEmails: [],
},
requestedPackageLineItems: [
{
weight: {
units: "LB",
value: 10,
},
dimensions: {
length: 12,
width: 8,
height: 6,
units: "IN",
},
},
],
},
accountNumber: { value: settings.fedex_account_number },
};
const shipRes = await fetch(`${base}/ship/v1/shipments`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"X-locale": "en_US",
},
body: JSON.stringify(createShipmentRequest),
});
if (!shipRes.ok) {
const text = await shipRes.text();
return { success: false, error: `FedEx shipment creation failed: ${text.slice(0, 300)}` };
}
const shipData = await shipRes.json();
const trackingNumber = shipData?.output?.pieceResponses?.[0]?.trackingNumber ?? "";
const labelUrl = shipData?.output?.pieceResponses?.[0]?.label?.url ?? "";
const fedexShipmentId = shipData?.output?.shipmentIdentification ?? "";
// Persist the shipment record. The `shipments` table is part of
// the legacy shipping surface; we attempt the insert and surface
// a friendly error if the table is gone (e.g. brand has not yet
// been migrated to the SaaS rebuild).
let shipmentId = "";
try {
const { rows: ins } = await pool.query<{ id: string }>(
`INSERT INTO shipments (
order_id, carrier, service_type, tracking_number, label_url,
rate_charged, estimated_delivery_date, is_refrigerated, is_fragile,
handling_notes, status, fedex_shipment_id, created_by
) VALUES (
$1, 'fedex', $2, $3, $4,
$5, $6, $7, $8,
$9, 'created', $10, $11
) RETURNING id::text AS id`,
[
orderId,
serviceType,
trackingNumber,
labelUrl,
rateCharged / 100,
estimatedDeliveryDate,
perishable,
false,
perishable ? (settings.refrigerated_handling_notes ?? null) : (settings.fragile_handling_notes ?? null),
fedexShipmentId || null,
adminUser.user_id,
]
);
shipmentId = ins[0]?.id ?? "";
} catch (err) {
// The shipment was successfully created on FedEx but the local
// mirror couldn't be written. Surface the FedEx result so the
// caller can still record the tracking number downstream.
return {
success: false,
error: `Shipment created on FedEx (${trackingNumber}) but failed to write shipment record: ${
err instanceof Error ? err.message : String(err)
}`,
};
}
if (!shipmentId) {
return { success: false, error: "Shipment created but failed to retrieve shipment ID" };
}
// Update order shipping_status and tracking_number via the legacy
// RPC. If the RPC is gone (SaaS rebuild) we silently skip — the
// `shipments` row above is the source of truth on the new schema.
try {
await pool.query(
"SELECT update_shipping_order($1, $2, $3, $4)",
[orderId, "label_created", trackingNumber, effectiveBrandId]
);
} catch {
// no-op
}
return {
success: true,
shipmentId,
trackingNumber,
labelUrl,
};
}