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 { 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 { 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 { 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 { 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 }): Promise; } /** * 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> { 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(); 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; }