datewise/app/lib/guard.server.ts
metatroncubeswdev 85ed1a8f0e feat: delivery-label and payment-rules checkout functions with Settings toggles
- delivery-label renames only options matching the chosen method (en/fr dates)
- payment-rules hides COD / pay-in-store for shipped orders
- functions matched by handle (fixes guard lookup matching any 'datewise' title)
- scopes: write_delivery_customizations, write_payment_customizations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:21:55 -04:00

99 lines
4.8 KiB
TypeScript

/**
* Datewise's checkout functions and their activation records, shared by Settings and the Today setup guide.
* - checkout guard → a cart & checkout validation
* - delivery label → a delivery customization
* - payment rules → a payment customization
* Functions are matched by extension handle, never by title, so one can't be mistaken for another.
*/
export const GUARD_HANDLE = "datewise-checkout-guard";
export const DELIVERY_LABEL_HANDLE = "datewise-delivery-label";
export const PAYMENT_RULES_HANDLE = "datewise-payment-rules";
export type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
export async function gql<T>(admin: AdminGraphql, q: string, variables?: Record<string, unknown>) {
const r = await admin.graphql(q, { variables });
return ((await r.json()) as { data: T }).data;
}
type Fn = { id: string; handle: string };
type Activation = { id: string; enabled: boolean; shopifyFunction: { id: string } };
export async function findGuard(admin: AdminGraphql) {
const d = await gql<{ shopifyFunctions: { nodes: Fn[] }; validations: { nodes: Activation[] } }>(admin, `#graphql
query DatewiseGuard {
shopifyFunctions(first: 50) { nodes { id handle } }
validations(first: 25) { nodes { id enabled shopifyFunction { id } } }
}`);
const fn = d.shopifyFunctions.nodes.find((f) => f.handle === GUARD_HANDLE);
const validation = fn ? d.validations.nodes.find((v) => v.shopifyFunction.id === fn.id) : undefined;
return { fn, validation };
}
export type CustomizationKind = "delivery" | "payment";
export type CustomizationState = { deployed: boolean; enabled: boolean; id?: string };
/** Separate from findGuard: needs the delivery/payment customization scopes, which may not be granted yet. */
export async function findCustomizations(admin: AdminGraphql): Promise<Record<CustomizationKind, CustomizationState>> {
const d = await gql<{
shopifyFunctions: { nodes: Fn[] };
deliveryCustomizations: { nodes: Activation[] };
paymentCustomizations: { nodes: Activation[] };
}>(admin, `#graphql
query DatewiseCustomizations {
shopifyFunctions(first: 50) { nodes { id handle } }
deliveryCustomizations(first: 25) { nodes { id enabled shopifyFunction { id } } }
paymentCustomizations(first: 25) { nodes { id enabled shopifyFunction { id } } }
}`);
const state = (handle: string, list: Activation[]): CustomizationState => {
const fn = d.shopifyFunctions.nodes.find((f) => f.handle === handle);
const act = fn ? list.find((c) => c.shopifyFunction.id === fn.id) : undefined;
return { deployed: !!fn, enabled: !!act?.enabled, id: act?.id };
};
return {
delivery: state(DELIVERY_LABEL_HANDLE, d.deliveryCustomizations.nodes),
payment: state(PAYMENT_RULES_HANDLE, d.paymentCustomizations.nodes),
};
}
const CUSTOMIZATION = {
delivery: {
handle: DELIVERY_LABEL_HANDLE,
title: "Datewise: show chosen date and time",
create: `#graphql
mutation DeliveryLabelCreate($input: DeliveryCustomizationInput!) {
deliveryCustomizationCreate(deliveryCustomization: $input) { deliveryCustomization { id } userErrors { message } }
}`,
update: `#graphql
mutation DeliveryLabelUpdate($id: ID!, $input: DeliveryCustomizationInput!) {
deliveryCustomizationUpdate(id: $id, deliveryCustomization: $input) { userErrors { message } }
}`,
},
payment: {
handle: PAYMENT_RULES_HANDLE,
title: "Datewise: hide pay-in-person for shipping",
create: `#graphql
mutation PaymentRulesCreate($input: PaymentCustomizationInput!) {
paymentCustomizationCreate(paymentCustomization: $input) { paymentCustomization { id } userErrors { message } }
}`,
update: `#graphql
mutation PaymentRulesUpdate($id: ID!, $input: PaymentCustomizationInput!) {
paymentCustomizationUpdate(id: $id, paymentCustomization: $input) { userErrors { message } }
}`,
},
} as const;
/** Turns a customization on or off, creating its activation record the first time. Returns an error message or null. */
export async function setCustomization(admin: AdminGraphql, kind: CustomizationKind, on: boolean): Promise<string | null> {
const current = (await findCustomizations(admin))[kind];
if (!current.deployed) return "This feature isn't deployed yet. Run `shopify app deploy`.";
const c = CUSTOMIZATION[kind];
type Res = Record<string, { userErrors: { message: string }[] }>;
let res: Res;
if (current.id) res = await gql<Res>(admin, c.update, { id: current.id, input: { enabled: on } });
else if (on) res = await gql<Res>(admin, c.create, { input: { functionHandle: c.handle, title: c.title, enabled: true } });
else return null;
const errors = Object.values(res ?? {}).flatMap((x) => x?.userErrors ?? []);
return errors.length ? errors.map((e) => e.message).join(" ") : null;
}