- Prisma: Zone (postal-code list or radius), Rate (zone- or distance-band
keyed), GeocodeCache (permanent address->lat/lng cache per
IMPLEMENTATION_PLAN.md §9), Location.shopifyLocationId (maps to
Shopify's own Location resource for inventory checks), Booking.zoneId
(needed for per-zone delivery-density counts, not just per-location).
- app/lib/geo.ts: pure haversine distance + postal-code matching, unit
tested against known city-to-city distances.
- app/services/zones.server.ts: geocoding (Google Maps Geocoding API,
cached — never re-geocodes the same address twice), zone eligibility,
nearest-location auto-assignment ranked by distance, delivery-density
threshold checks (a sparse zone doesn't unlock until minOrders bookings
have already routed through it), and inventory-based location exclusion
via Shopify's InventoryLevel API (locations without a mapped
shopifyLocationId are left in rather than false-negative excluded).
- app/services/rates.server.ts: pure rate resolution by zone or distance
band, cheapest-match-wins when bands overlap.
- apps.scheduling.availability.tsx: LOCAL_DELIVERY requests with a
postalCode/address now auto-assign to the nearest eligible,
density-qualified zone/location instead of the shop's default location;
response includes the matched rate. Also fixed a real gap left over from
Phase 4: this route never actually read Booking counts into
getAvailability's `consumed` map, so capacity always showed as fully
available regardless of existing bookings — now it does.
- extensions/datetime-widget: LOCAL_DELIVERY now asks for a postal code
before showing dates; PICKUP shows a Google Maps pin for the location
(both gated on an optional Maps API key — a block setting in the theme
editor, since it needs to be public/client-side, not an app secret);
confirmation display and cart attributes (dd_zone_id, dd_rate_label)
carry the resolved zone/rate through to checkout.
- extensions/delivery-customization: now appends the resolved rate to the
relabeled delivery option ("Local delivery — Aug 25 ($5.99)") when one's
configured — real Cart Transform-based fee *charging* stays deferred to
v2 per IMPLEMENTATION_PLAN.md §5.4, this is display-only.
- Admin: /app/zones and /app/rates (Polaris CRUD, mirroring Phase 1's
patterns), plus shopifyLocationId and auto-geocode-on-save added to the
location edit form.
Fixed one real bug caught only by `npm run build` (not tsc/vitest, which
both passed clean): app.rates._index.tsx's component called
formatPriceLabel from rates.server.ts, and Remix correctly refuses to
bundle anything imported from a .server.ts path for the client. Moved the
pure (no I/O, no Prisma) formatter to app/lib/currency.ts.
Verified: lint, typecheck, 86 unit tests (+21 new: geo, zones, rates,
delivery-customization's rate-label case with a real WASM fixture run),
16 integration tests against live Postgres (+8 new: geocode caching,
postal/radius zone matching, nearest-first ranking, density thresholds),
both builds, and a live script exercising the full
zone-match -> density-check -> rate-resolve -> availability pipeline
together against the Postgres container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
225 lines
7.6 KiB
TypeScript
225 lines
7.6 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"
|
|
postalCodes: string[];
|
|
radiusKm: number | null;
|
|
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.
|
|
*/
|
|
export function isZoneEligible(
|
|
zone: ZoneLike,
|
|
locationCoordinates: Coordinates | null,
|
|
customer: { coordinates?: Coordinates; postalCode?: string },
|
|
): 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;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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) {
|
|
const eligible = isZoneEligible(zone, locationCoordinates, {
|
|
coordinates: customerCoordinates ?? undefined,
|
|
postalCode: customer.postalCode,
|
|
});
|
|
if (!eligible) continue;
|
|
|
|
const distanceKm =
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|