Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
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>
167 lines
5.9 KiB
TypeScript
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: [],
|
|
}));
|
|
}
|