Icon rebuilt as a full-bleed 1200x1200 square (Shopify rounds corners itself). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
7.0 KiB
TypeScript
171 lines
7.0 KiB
TypeScript
// Publishes the compiled configuration to Shopify (brief §11.1, §11.5, §12):
|
|
// ONE automatic app discount per shop (function "bundle-discount") carrying $app:configuration and
|
|
// $app:function-vars, plus the app-data metafield bundle_offers.storefront read by the theme extension.
|
|
import db from "../db.server";
|
|
import { compile, settingsOf, type CompileResult } from "./compile";
|
|
import { gql, ensureShop, type AdminLike } from "./shop.server";
|
|
|
|
export const FUNCTION_HANDLE = "bundle-discount";
|
|
export const DISCOUNT_TITLE = "Offerlyst bundles";
|
|
export const STOREFRONT_NAMESPACE = "bundle_offers";
|
|
|
|
const CREATE = `#graphql
|
|
mutation BundleOffersCreateDiscount($d: DiscountAutomaticAppInput!) {
|
|
discountAutomaticAppCreate(automaticAppDiscount: $d) {
|
|
automaticAppDiscount { discountId }
|
|
userErrors { field message }
|
|
}
|
|
}`;
|
|
|
|
const UPDATE = `#graphql
|
|
mutation BundleOffersUpdateDiscount($id: ID!, $d: DiscountAutomaticAppInput!) {
|
|
discountAutomaticAppUpdate(id: $id, automaticAppDiscount: $d) {
|
|
automaticAppDiscount { discountId }
|
|
userErrors { field message }
|
|
}
|
|
}`;
|
|
|
|
const NODE = `#graphql
|
|
query BundleOffersDiscountNode($id: ID!) {
|
|
discountNode(id: $id) { id discount { ... on DiscountAutomaticApp { status } } }
|
|
}`;
|
|
|
|
const SET_METAFIELDS = `#graphql
|
|
mutation BundleOffersSetMetafields($m: [MetafieldsSetInput!]!) {
|
|
metafieldsSet(metafields: $m) { userErrors { field message code } }
|
|
}`;
|
|
|
|
const DELETE_DISCOUNT = `#graphql
|
|
mutation BundleOffersDeleteDiscount($id: ID!) {
|
|
discountAutomaticDelete(id: $id) { deletedAutomaticDiscountId userErrors { field message } }
|
|
}`;
|
|
|
|
const DELETE_METAFIELDS = `#graphql
|
|
mutation BundleOffersDeleteMetafields($m: [MetafieldIdentifierInput!]!) {
|
|
metafieldsDelete(metafields: $m) { userErrors { field message } }
|
|
}`;
|
|
|
|
function combinesWith(settingsRaw: unknown) {
|
|
const s = settingsOf(settingsRaw);
|
|
return { productDiscounts: s.combineProduct, orderDiscounts: s.combineOrder, shippingDiscounts: s.combineShipping };
|
|
}
|
|
|
|
function userErrorText(errors: { message: string }[] | undefined) {
|
|
return errors?.length ? errors.map((e) => e.message).join("; ") : null;
|
|
}
|
|
|
|
async function loadOffers(shopId: string) {
|
|
return db.offer.findMany({ where: { shopId }, include: { lines: { orderBy: { sequence: "asc" } }, scopes: true } });
|
|
}
|
|
|
|
export async function compileShop(shopId: string, now = new Date()): Promise<CompileResult> {
|
|
const shop = await db.shop.findUniqueOrThrow({ where: { id: shopId } });
|
|
const offers = await loadOffers(shopId);
|
|
return compile(
|
|
{ planCode: shop.planCode, currency: shop.currency, timezone: shop.timezone, settings: shop.settings, monthOrders: currentMonthOrders(shop) },
|
|
offers.map((o) => ({ ...o, lines: o.lines, scopes: o.scopes })),
|
|
now,
|
|
);
|
|
}
|
|
|
|
export function monthKey(d = new Date()) {
|
|
return d.toISOString().slice(0, 7);
|
|
}
|
|
|
|
export function currentMonthOrders(shop: { monthKey: string | null; monthOrders: number }) {
|
|
return shop.monthKey === monthKey() ? shop.monthOrders : 0;
|
|
}
|
|
|
|
/** Creates the shop's single automatic discount if it is missing; returns its id. */
|
|
async function ensureDiscount(admin: AdminLike, shop: { id: string; discountNodeId: string | null; settings: unknown }, metafields: object[]) {
|
|
if (shop.discountNodeId) {
|
|
const node = await gql(admin, NODE, { id: shop.discountNodeId });
|
|
if (node.discountNode) return { id: shop.discountNodeId, created: false };
|
|
}
|
|
const data = await gql(admin, CREATE, {
|
|
d: {
|
|
title: DISCOUNT_TITLE,
|
|
functionHandle: FUNCTION_HANDLE,
|
|
discountClasses: ["PRODUCT"],
|
|
startsAt: new Date().toISOString(),
|
|
endsAt: null,
|
|
combinesWith: combinesWith(shop.settings),
|
|
metafields,
|
|
},
|
|
});
|
|
const err = userErrorText(data.discountAutomaticAppCreate.userErrors);
|
|
if (err) throw new Error(`Could not create the Offerlyst discount: ${err}`);
|
|
const id = data.discountAutomaticAppCreate.automaticAppDiscount.discountId as string;
|
|
await db.shop.update({ where: { id: shop.id }, data: { discountNodeId: id } });
|
|
return { id, created: true };
|
|
}
|
|
|
|
export interface PublishOutcome {
|
|
ok: boolean;
|
|
error: string | null;
|
|
result: CompileResult;
|
|
}
|
|
|
|
/** Compiles and pushes everything to Shopify. Safe to call any time (idempotent). */
|
|
export async function publishShop(admin: AdminLike, domain: string, now = new Date()): Promise<PublishOutcome> {
|
|
const shop = await ensureShop(admin, domain);
|
|
const result = await compileShop(shop.id, now);
|
|
|
|
if (result.error) {
|
|
await db.shop.update({ where: { id: shop.id }, data: { publishError: result.error, nextSyncAt: result.nextSyncAt } });
|
|
return { ok: false, error: result.error, result };
|
|
}
|
|
|
|
const configMetafields = [
|
|
{ namespace: "$app", key: "configuration", type: "json", value: JSON.stringify(result.config) },
|
|
{ namespace: "$app", key: "function-vars", type: "json", value: JSON.stringify(result.vars) },
|
|
];
|
|
try {
|
|
const discount = await ensureDiscount(admin, shop, configMetafields);
|
|
const metafields: object[] = [
|
|
{ ownerId: shop.appInstallationId, namespace: STOREFRONT_NAMESPACE, key: "storefront", type: "json", value: JSON.stringify(result.storefront) },
|
|
];
|
|
if (!discount.created) metafields.push(...configMetafields.map((m) => ({ ...m, ownerId: discount.id })));
|
|
const set = await gql(admin, SET_METAFIELDS, { m: metafields });
|
|
const err = userErrorText(set.metafieldsSet.userErrors);
|
|
if (err) throw new Error(err);
|
|
|
|
await db.shop.update({
|
|
where: { id: shop.id },
|
|
data: {
|
|
publishError: null,
|
|
lastPublishedAt: now,
|
|
nextSyncAt: result.nextSyncAt,
|
|
configBytes: result.configBytes,
|
|
storefrontBytes: result.storefrontBytes,
|
|
},
|
|
});
|
|
return { ok: true, error: null, result };
|
|
} catch (e) {
|
|
const error = e instanceof Error ? e.message : String(e);
|
|
// retry in a minute
|
|
await db.shop.update({ where: { id: shop.id }, data: { publishError: error, nextSyncAt: new Date(Date.now() + 60_000) } });
|
|
return { ok: false, error, result };
|
|
}
|
|
}
|
|
|
|
/** Applies the combine settings to the discount node. */
|
|
export async function updateCombinations(admin: AdminLike, domain: string) {
|
|
const shop = await ensureShop(admin, domain);
|
|
if (!shop.discountNodeId) return publishShop(admin, domain);
|
|
const data = await gql(admin, UPDATE, { id: shop.discountNodeId, d: { combinesWith: combinesWith(shop.settings) } });
|
|
const err = userErrorText(data.discountAutomaticAppUpdate.userErrors);
|
|
if (err) throw new Error(err);
|
|
return publishShop(admin, domain);
|
|
}
|
|
|
|
/** Removes the discount and the storefront metafield (uninstall from inside the app, or plan cleanup). */
|
|
export async function removeShopifyData(admin: AdminLike, shop: { discountNodeId: string | null; appInstallationId: string | null }) {
|
|
if (shop.discountNodeId) await gql(admin, DELETE_DISCOUNT, { id: shop.discountNodeId }).catch(() => null);
|
|
if (shop.appInstallationId) {
|
|
await gql(admin, DELETE_METAFIELDS, {
|
|
m: [{ ownerId: shop.appInstallationId, namespace: STOREFRONT_NAMESPACE, key: "storefront" }],
|
|
}).catch(() => null);
|
|
}
|
|
}
|