bundle-offers-app/app/lib/publish.server.ts
metatroncubeswdev ba7a7c89a2 Rename app to Offerlyst: Bundles & BOGO; add App Store icon and brand assets
Icon rebuilt as a full-bleed 1200x1200 square (Shopify rounds corners itself).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:03:32 -04:00

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);
}
}