metatrondelivery/app/services/product-rules.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

161 lines
5.5 KiB
TypeScript

import type { Method } from "@prisma/client";
import type { AdminGraphQLClient } from "./zones.server";
// Product/collection/vendor/type/tag-scoped delivery rules (PRODUCT_STRATEGY.md
// §2 parity row — previously unimplemented; see extensions/validation-slot's
// evaluate.js comment acknowledging the gap this closes). Pure matching and
// constraint-combination logic lives here so it's exhaustively unit-testable,
// same as scheduling.server.ts; the one I/O function (resolveProductRefs) is
// kept separate and thin.
export interface ProductRuleLike {
scopeType: string; // product|collection|vendor|type|tag
scopeValue: string;
allowedMethods: Method[]; // empty = unrestricted
leadTimeMin: number | null;
allowedLocationIds: string[]; // empty = unrestricted
active: boolean;
}
export interface ProductRef {
id: string; // Shopify Product GID
vendor: string;
productType: string;
tags: string[];
collectionIds: string[];
}
/** Case-insensitive compare for free-text scopes (vendor/type/tag); exact for id-based scopes (product/collection). */
export function matchesRule(rule: ProductRuleLike, product: ProductRef): boolean {
if (!rule.active) return false;
const value = rule.scopeValue.trim().toLowerCase();
switch (rule.scopeType) {
case "product":
return product.id === rule.scopeValue;
case "collection":
return product.collectionIds.includes(rule.scopeValue);
case "vendor":
return product.vendor.trim().toLowerCase() === value;
case "type":
return product.productType.trim().toLowerCase() === value;
case "tag":
return product.tags.some((tag) => tag.trim().toLowerCase() === value);
default:
return false;
}
}
export interface ProductRuleConstraints {
/** Extra lead-time floor this cart's contents impose, on top of the slot's own leadTimeMin/cutoffMin. */
minLeadTimeMin: number;
/** null = no rule restricted methods for this cart. Empty array = every matching rule's restriction intersected to nothing (block everything). */
allowedMethods: Method[] | null;
/** Same null/empty-array convention as allowedMethods, for locations. */
allowedLocationIds: string[] | null;
}
/**
* Combines every rule matching anything in the cart: lead time is additive-worst-case
* (max across matches — the slowest item sets the floor for the whole order), while
* method/location restrictions intersect (an order can only use a method/location every
* matching rule permits). No matching rule at all means "unrestricted": null, not [].
*/
export function resolveProductRuleConstraints(
rules: ProductRuleLike[],
products: ProductRef[],
): ProductRuleConstraints {
const matched = rules.filter((rule) => products.some((product) => matchesRule(rule, product)));
let minLeadTimeMin = 0;
let allowedMethods: Method[] | null = null;
let allowedLocationIds: string[] | null = null;
for (const rule of matched) {
if (rule.leadTimeMin != null) {
minLeadTimeMin = Math.max(minLeadTimeMin, rule.leadTimeMin);
}
if (rule.allowedMethods.length > 0) {
allowedMethods = allowedMethods == null ? rule.allowedMethods : allowedMethods.filter((m) => rule.allowedMethods.includes(m));
}
if (rule.allowedLocationIds.length > 0) {
allowedLocationIds =
allowedLocationIds == null ? rule.allowedLocationIds : allowedLocationIds.filter((id) => rule.allowedLocationIds.includes(id));
}
}
return { minLeadTimeMin, allowedMethods, allowedLocationIds };
}
/**
* Resolves vendor/productType/tags/collection membership for a batch of Shopify
* product GIDs via the Admin API — same admin.graphql(...) + typed response.json()
* shape as zones.server.ts's excludeLocationsWithoutStock, for the same reason
* (testable with a mock admin client, no direct SDK import here).
*/
export async function resolveProductRefs(admin: AdminGraphQLClient, productIds: string[]): Promise<ProductRef[]> {
if (productIds.length === 0) return [];
const response = await admin.graphql(
`#graphql
query ProductRuleRefs($ids: [ID!]!) {
nodes(ids: $ids) {
... on Product {
id
vendor
productType
tags
collections(first: 50) {
nodes {
id
}
}
}
}
}`,
{ variables: { ids: productIds } },
);
const body = (await response.json()) as {
data?: {
nodes: Array<{
id: string;
vendor: string;
productType: string;
tags: string[];
collections: { nodes: Array<{ id: string }> };
} | null>;
};
};
return (body.data?.nodes ?? [])
.filter((node): node is NonNullable<typeof node> => node != null)
.map((node) => ({
id: node.id,
vendor: node.vendor,
productType: node.productType,
tags: node.tags,
collectionIds: node.collections.nodes.map((c) => c.id),
}));
}
/**
* Cheap, no-Admin-API-call constraint resolution for vendor/type/tag-scoped rules
* only, from data the storefront widget already has via /cart.js (vendor,
* product_type — no extra round-trip). Product/collection-scoped rules need
* resolveProductRefs + the full ProductRef; this is the degraded-but-free path
* used when no productIds were supplied (or the admin client is unavailable).
*/
export function productRefsFromCartLines(lines: Array<{ vendor?: string; productType?: string }>): ProductRef[] {
return lines.map((line) => ({
id: "",
vendor: line.vendor ?? "",
productType: line.productType ?? "",
tags: [],
collectionIds: [],
}));
}