/** * 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. */ import type { AppSettings } from "../services/settings"; 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 }) => Promise }; export async function gql(admin: AdminGraphql, q: string, variables?: Record) { 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> { 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 the checkout guard on or off, creating its validation the first time. Returns an error message or null. */ export async function setGuard(admin: AdminGraphql, on: boolean, requireScheduling: boolean): Promise { const { fn, validation } = await findGuard(admin); if (!fn) return "The checkout guard isn't deployed yet. Run `shopify app deploy`."; const metafields = [{ namespace: "$app:datewise", key: "config", type: "json", value: JSON.stringify({ requireScheduling }) }]; type Res = Record; let res: Res; if (validation) res = await gql(admin, `#graphql mutation($id: ID!, $v: ValidationUpdateInput!) { validationUpdate(id: $id, validation: $v) { userErrors { message } } }`, { id: validation.id, v: { enable: on, blockOnFailure: false, metafields }, }); else if (on) res = await gql(admin, `#graphql mutation($v: ValidationCreateInput!) { validationCreate(validation: $v) { userErrors { message } } }`, { v: { functionHandle: GUARD_HANDLE, title: "Datewise checkout guard", enable: true, blockOnFailure: false, metafields }, }); else return null; const errors = Object.values(res ?? {}).flatMap((x) => x?.userErrors ?? []); return errors.length ? errors.map((e) => e.message).join(" ") : null; } /** * Shopify deletes an app's validations and customizations on uninstall. On reinstall, switch back on * the ones the merchant had on. Each is tried separately so one failure doesn't block the others. */ export async function restoreCheckoutFunctions(admin: AdminGraphql, settings: AppSettings) { const { guard, delivery, payment } = settings.checkoutFunctions; const jobs: [string, () => Promise][] = []; if (guard) jobs.push(["guard", () => setGuard(admin, true, settings.requireScheduling)]); if (delivery) jobs.push(["delivery", () => setCustomization(admin, "delivery", true)]); if (payment) jobs.push(["payment", () => setCustomization(admin, "payment", true)]); for (const [name, run] of jobs) { const error = await run().catch((e: Error) => e.message); if (error) console.error(`[datewise] restore ${name} failed: ${error}`); } } /** 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 { 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; let res: Res; if (current.id) res = await gql(admin, c.update, { id: current.id, input: { enabled: on } }); else if (on) res = await gql(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; }