bundle-offers-app/app/routes/webhooks.catalog.tsx
metatroncubeswdev 365e45406a Bundle Offers Shopify app: discount function, admin, storefront, deploy stack
Rust discount function (one automatic discount evaluating all offers in priority
order, parity with the Odoo engine), React Router admin with Polaris, theme app
extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and
a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS.

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

53 lines
3.0 KiB
TypeScript

import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
// products/update: refresh cached titles, handles and images used by the admin and the storefront.
// products/delete, collections/delete: remove them from offers and log it in the offer history.
// A published fixed bundle that lost a product is unpublished so the merchant reviews it.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
const row = await db.shop.findUnique({ where: { domain: shop } });
if (!row) return new Response();
const p = payload as { id?: number | string; title?: string; handle?: string; image?: { src?: string } | null; images?: { src?: string }[] };
const id = String(p.id ?? "");
if (!id) return new Response();
const offerFilter = { offer: { shopId: row.id } };
if (topic === "PRODUCTS_UPDATE") {
const image = p.image?.src || p.images?.[0]?.src || null;
await db.offerLine.updateMany({ where: { ...offerFilter, productId: id, variantId: null }, data: { title: p.title } });
await db.offerLine.updateMany({ where: { ...offerFilter, productId: id }, data: { handle: p.handle, imageUrl: image } });
await db.offerScope.updateMany({ where: { ...offerFilter, kind: "product", refId: id }, data: { title: p.title, handle: p.handle, imageUrl: image } });
await db.offerScope.updateMany({ where: { ...offerFilter, kind: "variant", parentId: id }, data: { handle: p.handle, imageUrl: image } });
await db.shop.update({ where: { id: row.id }, data: { nextSyncAt: new Date() } });
return new Response();
}
const isProduct = topic === "PRODUCTS_DELETE";
const lines = isProduct ? await db.offerLine.findMany({ where: { ...offerFilter, productId: id } }) : [];
const scopes = await db.offerScope.findMany({
where: isProduct
? { ...offerFilter, OR: [{ kind: "product", refId: id }, { kind: "variant", parentId: id }] }
: { ...offerFilter, kind: "collection", refId: id },
});
if (!lines.length && !scopes.length) return new Response();
await db.offerLine.deleteMany({ where: { id: { in: lines.map((l) => l.id) } } });
await db.offerScope.deleteMany({ where: { id: { in: scopes.map((s) => s.id) } } });
const affected = [...new Set([...lines.map((l) => l.offerId), ...scopes.map((s) => s.offerId)])];
await db.auditLog.createMany({
data: affected.map((offerId) => ({
shopId: row.id,
offerId,
field: isProduct ? "product deleted" : "collection deleted",
oldValue: lines.find((l) => l.offerId === offerId)?.title || scopes.find((s) => s.offerId === offerId)?.title || id,
newValue: "removed from the offer",
})),
});
if (isProduct) {
await db.offer.updateMany({ where: { id: { in: affected }, type: "fixed", published: true }, data: { published: false } });
}
await db.shop.update({ where: { id: row.id }, data: { nextSyncAt: new Date() } });
return new Response();
};