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 { 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 => 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: [], })); }