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>
161 lines
5.5 KiB
TypeScript
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: [],
|
|
}));
|
|
}
|