Closes remaining DS-parity gaps from the feature audit: - ProductRule model (product/collection/vendor/type/tag scoping) with real server-side enforcement in hold-request.server.ts, plus shaped availability in availability-request.server.ts. Covers per-product prep time, cart-content-based slot blocking, and product-restricted locations in one mechanism. New /app/rules admin page (Growth+). - Driving-distance delivery zones via Google's Distance Matrix API, cached like existing geocoding results. - SHIPPING-only estimated arrival range (transitMinDays/transitMaxDays on SlotTemplate) — widget shows "Arrives Thu-Sat" instead of a meaningless ship-out time slot; carried through to the order metafield write-back. Storefront widget and POS extension now send cart contents (vendor/ type from cart.js, product ids for Admin-API-resolved collection/tag rules) to both availability and hold endpoints. checkout-datetime remains excluded from this deploy pending Shopify's Network Access approval (unrelated to this work) — re-add from ../checkout-datetime-disabled and redeploy once granted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
import db from "../db.server";
|
|
import { haversineDistanceKm, isPostalCodeListed, type Coordinates } from "../lib/geo";
|
|
|
|
// I/O orchestration around the pure math in lib/geo.ts. Geocoding results
|
|
// are cached permanently per IMPLEMENTATION_PLAN.md §9 ("cache geocode
|
|
// results per address; don't call the maps API on every availability
|
|
// request") — addresses don't move, so there's no cache invalidation to
|
|
// worry about, only cache growth, which is fine for this volume.
|
|
|
|
export interface ZoneLike {
|
|
id: string;
|
|
locationId: string;
|
|
type: string; // "postal" | "radius" | "driving"
|
|
postalCodes: string[];
|
|
radiusKm: number | null; // for "driving" zones, this is the max driving distance in km
|
|
minOrders: number | null;
|
|
active: boolean;
|
|
}
|
|
|
|
function normalizeAddressKey(address: string): string {
|
|
return address.trim().toLowerCase().replace(/\s+/g, " ");
|
|
}
|
|
|
|
/**
|
|
* Geocodes an address via the Google Maps Geocoding API, using
|
|
* GOOGLE_MAPS_API_KEY. Returns null (rather than throwing) when no key is
|
|
* configured or the address can't be resolved — callers should treat that
|
|
* as "can't determine eligibility," not a hard error, since a merchant may
|
|
* not have set up Maps yet.
|
|
*/
|
|
export async function geocodeAddress(address: string): Promise<Coordinates | null> {
|
|
const key = normalizeAddressKey(address);
|
|
|
|
const cached = await db.geocodeCache.findUnique({ where: { normalizedKey: key } });
|
|
if (cached) return { lat: cached.lat, lng: cached.lng };
|
|
|
|
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
|
if (!apiKey) return null;
|
|
|
|
const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
|
|
url.searchParams.set("address", address);
|
|
url.searchParams.set("key", apiKey);
|
|
|
|
const res = await fetch(url.toString());
|
|
if (!res.ok) return null;
|
|
|
|
const body = (await res.json()) as {
|
|
status: string;
|
|
results: Array<{ geometry: { location: { lat: number; lng: number } } }>;
|
|
};
|
|
if (body.status !== "OK" || body.results.length === 0) return null;
|
|
|
|
const { lat, lng } = body.results[0].geometry.location;
|
|
await db.geocodeCache.upsert({
|
|
where: { normalizedKey: key },
|
|
create: { normalizedKey: key, lat, lng },
|
|
update: { lat, lng },
|
|
});
|
|
|
|
return { lat, lng };
|
|
}
|
|
|
|
/**
|
|
* Radius zones use the location's own lat/lng as the center — postal zones
|
|
* need no coordinates at all. This is why isZoneEligible takes the
|
|
* location's coordinates separately rather than assuming the zone carries
|
|
* its own center point.
|
|
*
|
|
* "driving" zones need a road-network distance, which is an API call (see
|
|
* resolveDrivingDistanceKm) — this function stays pure/synchronous (no I/O
|
|
* in the math, CLAUDE.md), so the caller resolves that distance first and
|
|
* passes it in via `customer.drivingDistanceKm`, exactly the way it already
|
|
* resolves geocoding before calling this for radius zones.
|
|
*/
|
|
export function isZoneEligible(
|
|
zone: ZoneLike,
|
|
locationCoordinates: Coordinates | null,
|
|
customer: { coordinates?: Coordinates; postalCode?: string; drivingDistanceKm?: number },
|
|
): boolean {
|
|
if (!zone.active) return false;
|
|
|
|
if (zone.type === "postal") {
|
|
return customer.postalCode ? isPostalCodeListed(customer.postalCode, zone.postalCodes) : false;
|
|
}
|
|
|
|
if (zone.type === "radius") {
|
|
if (!locationCoordinates || !customer.coordinates || zone.radiusKm == null) return false;
|
|
return haversineDistanceKm(customer.coordinates, locationCoordinates) <= zone.radiusKm;
|
|
}
|
|
|
|
if (zone.type === "driving") {
|
|
if (zone.radiusKm == null || customer.drivingDistanceKm == null) return false;
|
|
return customer.drivingDistanceKm <= zone.radiusKm;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function roundCoord(n: number): number {
|
|
return Math.round(n * 10000) / 10000; // ~11m precision — plenty for a zone-eligibility cache key
|
|
}
|
|
|
|
function routeKey(origin: Coordinates, destination: Coordinates): string {
|
|
return `${roundCoord(origin.lat)},${roundCoord(origin.lng)}|${roundCoord(destination.lat)},${roundCoord(destination.lng)}`;
|
|
}
|
|
|
|
/**
|
|
* Road distance between two points via Google's Distance Matrix API, using
|
|
* the same GOOGLE_MAPS_API_KEY as geocodeAddress. Returns null (not a throw)
|
|
* when there's no key or the API can't route between the points — callers
|
|
* must treat that as "can't determine eligibility" (isZoneEligible's driving
|
|
* branch already fails closed on null), the same permissive-to-the-API,
|
|
* fail-closed-on-eligibility posture as the rest of this file.
|
|
*/
|
|
export async function resolveDrivingDistanceKm(origin: Coordinates, destination: Coordinates): Promise<number | null> {
|
|
const key = routeKey(origin, destination);
|
|
|
|
const cached = await db.drivingDistanceCache.findUnique({ where: { routeKey: key } });
|
|
if (cached) return cached.distanceKm;
|
|
|
|
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
|
if (!apiKey) return null;
|
|
|
|
const url = new URL("https://maps.googleapis.com/maps/api/distancematrix/json");
|
|
url.searchParams.set("origins", `${origin.lat},${origin.lng}`);
|
|
url.searchParams.set("destinations", `${destination.lat},${destination.lng}`);
|
|
url.searchParams.set("key", apiKey);
|
|
|
|
const res = await fetch(url.toString());
|
|
if (!res.ok) return null;
|
|
|
|
const body = (await res.json()) as {
|
|
status: string;
|
|
rows: Array<{ elements: Array<{ status: string; distance?: { value: number } }> }>;
|
|
};
|
|
const element = body.rows?.[0]?.elements?.[0];
|
|
if (body.status !== "OK" || !element || element.status !== "OK" || !element.distance) return null;
|
|
|
|
const distanceKm = element.distance.value / 1000;
|
|
await db.drivingDistanceCache.upsert({
|
|
where: { routeKey: key },
|
|
create: { routeKey: key, distanceKm },
|
|
update: { distanceKm },
|
|
});
|
|
|
|
return distanceKm;
|
|
}
|
|
|
|
export interface EligibleLocationMatch {
|
|
locationId: string;
|
|
zoneId: string;
|
|
distanceKm: number | null;
|
|
}
|
|
|
|
/**
|
|
* Nearest-location auto-assign (PRODUCT_STRATEGY.md §2 "Auto location
|
|
* assignment"): given a shopper's address, finds every active zone across
|
|
* the shop's locations that covers it, ranked nearest-first when distance
|
|
* is known (radius zones) — postal zones without location coordinates sort
|
|
* after distance-ranked ones, in the order returned by the query.
|
|
*/
|
|
export async function findEligibleLocationsForDelivery(
|
|
shopDomain: string,
|
|
customer: { address?: string; postalCode?: string },
|
|
): Promise<EligibleLocationMatch[]> {
|
|
const customerCoordinates = customer.address ? await geocodeAddress(customer.address) : null;
|
|
|
|
const locations = await db.location.findMany({
|
|
where: { shopDomain, active: true },
|
|
include: { zones: { where: { active: true } } },
|
|
});
|
|
|
|
const matches: EligibleLocationMatch[] = [];
|
|
|
|
for (const location of locations) {
|
|
const locationCoordinates = location.lat != null && location.lng != null ? { lat: location.lat, lng: location.lng } : null;
|
|
|
|
for (const zone of location.zones) {
|
|
// "driving" zones need a road-distance lookup before isZoneEligible can
|
|
// even evaluate them — resolved (and cached) here, not inside the pure
|
|
// eligibility check itself.
|
|
let drivingDistanceKm: number | undefined;
|
|
if (zone.type === "driving" && locationCoordinates && customerCoordinates) {
|
|
// eslint-disable-next-line no-await-in-loop -- one cached lookup per candidate zone; there's no batch API to move this out of the loop
|
|
drivingDistanceKm = (await resolveDrivingDistanceKm(customerCoordinates, locationCoordinates)) ?? undefined;
|
|
}
|
|
|
|
const eligible = isZoneEligible(zone, locationCoordinates, {
|
|
coordinates: customerCoordinates ?? undefined,
|
|
postalCode: customer.postalCode,
|
|
drivingDistanceKm,
|
|
});
|
|
if (!eligible) continue;
|
|
|
|
const distanceKm =
|
|
drivingDistanceKm ??
|
|
(locationCoordinates && customerCoordinates ? haversineDistanceKm(customerCoordinates, locationCoordinates) : null);
|
|
|
|
matches.push({ locationId: location.id, zoneId: zone.id, distanceKm });
|
|
}
|
|
}
|
|
|
|
return matches.sort((a, b) => {
|
|
if (a.distanceKm == null && b.distanceKm == null) return 0;
|
|
if (a.distanceKm == null) return 1;
|
|
if (b.distanceKm == null) return -1;
|
|
return a.distanceKm - b.distanceKm;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delivery-density threshold (PRODUCT_STRATEGY.md §3.2): a sparse zone
|
|
* shouldn't unlock delivery capacity until `minOrders` bookings have
|
|
* already routed through it — cuts delivery cost on routes that wouldn't
|
|
* be worth a driver trip for just one or two orders. A zone with no
|
|
* minOrders set (or 0) always passes.
|
|
*/
|
|
export async function meetsDeliveryDensity(shopDomain: string, zone: ZoneLike): Promise<boolean> {
|
|
if (!zone.minOrders || zone.minOrders <= 0) return true;
|
|
|
|
const count = await db.booking.count({
|
|
where: { shopDomain, zoneId: zone.id, status: { in: ["confirmed", "fulfilled"] } },
|
|
});
|
|
|
|
return count >= zone.minOrders;
|
|
}
|
|
|
|
export interface AdminGraphQLClient {
|
|
graphql(query: string, options?: { variables?: Record<string, unknown> }): Promise<Response>;
|
|
}
|
|
|
|
/**
|
|
* Inventory-based location exclusion (PRODUCT_STRATEGY.md §2): drops any
|
|
* candidate location that doesn't stock at least one of the cart's
|
|
* products, per Shopify's InventoryLevel API. Locations without a
|
|
* shopifyLocationId mapping are left in (can't check what we can't query —
|
|
* excluding them would be a false negative, not a safe default) and this
|
|
* whole check is a no-op when productIds is empty (nothing to check stock
|
|
* for, e.g. availability requests made before a cart exists).
|
|
*/
|
|
export async function excludeLocationsWithoutStock(
|
|
admin: AdminGraphQLClient,
|
|
locationIds: string[],
|
|
productVariantGids: string[],
|
|
): Promise<Set<string>> {
|
|
if (productVariantGids.length === 0) return new Set(locationIds);
|
|
|
|
const locations = await db.location.findMany({
|
|
where: { id: { in: locationIds } },
|
|
select: { id: true, shopifyLocationId: true },
|
|
});
|
|
|
|
const inStock = new Set<string>();
|
|
|
|
for (const location of locations) {
|
|
if (!location.shopifyLocationId) {
|
|
inStock.add(location.id); // unmapped — can't verify, don't exclude
|
|
continue;
|
|
}
|
|
|
|
const response = await admin.graphql(
|
|
`#graphql
|
|
query LocationStock($locationId: ID!, $variantIds: [ID!]!) {
|
|
location(id: $locationId) {
|
|
id
|
|
}
|
|
nodes(ids: $variantIds) {
|
|
... on ProductVariant {
|
|
inventoryItem {
|
|
inventoryLevel(locationId: $locationId) {
|
|
quantities(names: ["available"]) {
|
|
quantity
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
{ variables: { locationId: location.shopifyLocationId, variantIds: productVariantGids } },
|
|
);
|
|
|
|
const body = (await response.json()) as {
|
|
data?: {
|
|
nodes: Array<{ inventoryItem?: { inventoryLevel?: { quantities: Array<{ quantity: number }> } | null } } | null>;
|
|
};
|
|
};
|
|
|
|
const hasStock = (body.data?.nodes ?? []).some((node) =>
|
|
(node?.inventoryItem?.inventoryLevel?.quantities ?? []).some((q) => q.quantity > 0),
|
|
);
|
|
|
|
if (hasStock) inStock.add(location.id);
|
|
}
|
|
|
|
return inStock;
|
|
}
|