metatrondelivery/app/services/zones.server.ts
MOHAN 03574a4914 feat: product rules, driving-distance zones, and shipping date ranges
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>
2026-08-26 00:28:45 +05:30

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;
}