metatrondelivery/app/services/product-rules.server.ts
metatroncubeswdev a2c78d703f
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
feat: close DS study coverage gaps (inventory exclusion, live checkout re-validation, per-day cap, payment fn, checkout ext)
Audited the implementation against DS_Delivery_Date_Time_App_Study.docx and
closed the actionable gaps (see IMPLEMENTATION_REVIEW_2026-09-04.md).

Core (code + unit tests, 156 green):
- Wire excludeLocationsWithoutStock into resolveAvailabilityRequest; widget
  now sends variantIds so inventory-based location exclusion actually runs.
- Live slot re-validation at checkout: new checkout-snapshot.server.ts writes
  a shop-metafield capacity snapshot; validation-slot's evaluateCheckout
  rejects a complete selection that has since filled / blacked out / closed /
  hit the daily cap / left the schedule. Refreshed on order webhooks and
  slot/blackout/location/enforcement edits.
- Scopable checkout enforcement: Shop.enforcementMode (all|tagged|off) +
  enforcementTag, new app.settings.tsx admin page, honoured via the snapshot.
- Per-day order cap: Location.dailyOrderCap threaded through getAvailability
  (dailyCap + consumedPerDate); admin field on the location screen.
- Product-rule slot blocking: ProductRule.blockedStartMins, unioned in
  resolveProductRuleConstraints, enforced in the engine and resolveHoldRequest;
  admin field on the product rules screen.
- Product-page placement: product-availability.liquid block + widget
  data-mode="preview" (read-only earliest-date line).
- Second locale: datetime-widget fr.json / fr.schema.json.
- Migration 20260904120000_review_gaps (apply with prisma migrate deploy).

New Functions (source + unit tests; need `shopify app deploy` to ship):
- extensions/payment-customization: cart.payment-methods.transform.run — hides
  cash-on-delivery / pay-in-store gateways on SHIPPING orders.
- extensions/checkout-datetime/src: restored from a gitignored dist-only state
  — Plus native picker + Thank you / Order status confirmation blocks, all
  calling the existing checkout.scheduling.* routes (one capacity pool).
  tsconfig ships checkJs:false pending reconciliation with live checkout types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 01:31:02 -04:00

167 lines
5.9 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
blockedStartMins?: number[]; // slot start-minutes this rule forbids for a matching cart
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;
/** Union of every matching rule's forbidden slot start-minutes (study §3.5). Empty = nothing blocked. */
blockedStartMins: number[];
}
/**
* 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;
const blockedStartMins = new Set<number>();
for (const rule of matched) {
if (rule.leadTimeMin != null) {
minLeadTimeMin = Math.max(minLeadTimeMin, rule.leadTimeMin);
}
for (const min of rule.blockedStartMins ?? []) blockedStartMins.add(min);
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, blockedStartMins: [...blockedStartMins].sort((a, b) => a - b) };
}
/**
* 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: [],
}));
}