bundle-offers-app/app/lib/offers.server.ts
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

197 lines
8.7 KiB
TypeScript

import type { Prisma } from "@prisma/client";
import db from "../db.server";
import { slugify } from "./offer-text";
import { validateOffer, type Errors, type OfferInput } from "./validation";
export interface OfferFormLine { productId: string; variantId?: string | null; quantity: number; title?: string | null; handle?: string | null; imageUrl?: string | null }
export interface OfferFormScope { role: string; kind: string; refId: string; parentId?: string | null; title?: string | null; handle?: string | null; imageUrl?: string | null }
export interface OfferForm extends OfferInput {
published: boolean;
active?: boolean;
countries: string[];
showHome: boolean;
showProduct: boolean;
showCartHints: boolean;
imageUrl?: string | null;
i18n?: Record<string, { name?: string; badge?: string; description?: string }>;
lines: OfferFormLine[];
scopes: OfferFormScope[];
}
export const offerInclude = {
lines: { orderBy: { sequence: "asc" } },
scopes: true,
} satisfies Prisma.OfferInclude;
export type OfferWithRelations = Prisma.OfferGetPayload<{ include: typeof offerInclude }>;
const TRACKED = ["name", "type", "discountType", "discountValue", "startsAt", "endsAt", "published", "active"] as const;
async function uniqueHandle(shopId: string, name: string, exceptId?: string) {
const base = slugify(name);
for (let i = 0; i < 1000; i += 1) {
const handle = i ? `${base}-${i + 1}` : base;
const clash = await db.offer.findFirst({
where: { shopId, id: exceptId ? { not: exceptId } : undefined, OR: [{ handle }, { oldHandles: { has: handle } }] },
select: { id: true },
});
if (!clash) return handle;
}
return `${base}-${Date.now()}`;
}
async function nextCode(shopId: string) {
const count = await db.offer.count({ where: { shopId } });
for (let i = count + 1; ; i += 1) {
const code = `o${i.toString(36)}`;
if (!(await db.offer.findFirst({ where: { shopId, code }, select: { id: true } }))) return code;
}
}
const fmt = (v: unknown) => (v instanceof Date ? v.toISOString() : v == null ? "" : String(v));
function data(form: OfferForm) {
return {
name: form.name.trim(),
badge: form.badge?.trim() || null,
description: form.description?.trim() || null,
i18n: (form.i18n || {}) as Prisma.InputJsonValue,
sequence: Math.round(form.sequence) || 10,
published: !!form.published,
active: form.active ?? true,
countries: form.countries.map((c) => c.trim().toUpperCase()).filter((c) => /^[A-Z]{2}$/.test(c)),
type: form.type,
discountType: form.discountType,
discountValue: Number(form.discountValue) || 0,
mixQty: Math.round(form.mixQty),
buyQty: Math.round(form.buyQty),
getQty: Math.round(form.getQty),
getPercent: Number(form.getPercent) || 0,
startsAt: form.startsAt ? new Date(form.startsAt) : null,
endsAt: form.endsAt ? new Date(form.endsAt) : null,
maxPerOrder: Math.round(form.maxPerOrder) || 0,
showHome: !!form.showHome,
showProduct: !!form.showProduct,
showCartHints: !!form.showCartHints,
imageUrl: form.imageUrl || null,
};
}
export async function saveOffer(shopId: string, id: string | null, form: OfferForm, staffUser?: string): Promise<{ errors?: Errors; offer?: OfferWithRelations }> {
const errors = validateOffer(form);
if (Object.keys(errors).length) return { errors };
const values = data(form);
// keep only the relevant products for the chosen type
const lines = form.type === "fixed" ? form.lines : [];
const scopes = form.scopes.filter((s) => (form.type === "mix" ? s.role === "mix" : form.type === "bxgy" ? s.role === "buy" || s.role === "get" : false));
const offer = await db.$transaction(async (tx) => {
const before = id ? await tx.offer.findFirst({ where: { id, shopId } }) : null;
if (id && !before) throw new Response("Not found", { status: 404 });
let handle = before?.handle;
let oldHandles = before?.oldHandles || [];
if (!before || before.name !== values.name) {
const next = await uniqueHandle(shopId, values.name, id || undefined);
if (before && next !== before.handle) oldHandles = [...new Set([...oldHandles, before.handle])].filter((h) => h !== next);
handle = next;
}
const saved = before
? await tx.offer.update({ where: { id: before.id }, data: { ...values, handle, oldHandles } })
: await tx.offer.create({ data: { ...values, shopId, handle: handle!, code: await nextCode(shopId) } });
await tx.offerLine.deleteMany({ where: { offerId: saved.id } });
await tx.offerScope.deleteMany({ where: { offerId: saved.id } });
if (lines.length) {
await tx.offerLine.createMany({
data: lines.map((l, i) => ({
offerId: saved.id, sequence: (i + 1) * 10, productId: String(l.productId), variantId: l.variantId || null,
quantity: Math.round(l.quantity), title: l.title || null, handle: l.handle || null, imageUrl: l.imageUrl || null,
})),
});
}
if (scopes.length) {
await tx.offerScope.createMany({
data: scopes.map((s) => ({
offerId: saved.id, role: s.role, kind: s.kind, refId: String(s.refId), parentId: s.parentId || null,
title: s.title || null, handle: s.handle || null, imageUrl: s.imageUrl || null,
})),
});
}
const audit = TRACKED.filter((f) => fmt(before?.[f]) !== fmt(saved[f]))
.filter((f) => before || ["name", "type", "published"].includes(f))
.map((f) => ({ shopId, offerId: saved.id, staffUser: staffUser || null, field: f, oldValue: before ? fmt(before[f]) : null, newValue: fmt(saved[f]) }));
if (audit.length) await tx.auditLog.createMany({ data: audit });
return tx.offer.findUniqueOrThrow({ where: { id: saved.id }, include: offerInclude });
});
return { offer };
}
export async function duplicateOffer(shopId: string, id: string, staffUser?: string) {
const o = await db.offer.findFirst({ where: { id, shopId }, include: offerInclude });
if (!o) return null;
const { offer } = await saveOffer(shopId, null, {
...o,
name: `${o.name} (copy)`.slice(0, 80),
discountValue: Number(o.discountValue),
getPercent: Number(o.getPercent),
startsAt: o.startsAt?.toISOString() || null,
endsAt: o.endsAt?.toISOString() || null,
published: false,
i18n: o.i18n as OfferForm["i18n"],
}, staffUser);
return offer;
}
export async function bulkAction(shopId: string, ids: string[], action: string, staffUser?: string) {
if (!ids.length) return;
if (action === "duplicate") {
for (const id of ids) await duplicateOffer(shopId, id, staffUser);
return;
}
const change: Prisma.OfferUpdateManyMutationInput | null =
action === "publish" ? { published: true, limitedByPlan: false }
: action === "unpublish" ? { published: false }
: action === "archive" ? { active: false, published: false }
: action === "unarchive" ? { active: true }
: null;
if (change) {
await db.offer.updateMany({ where: { shopId, id: { in: ids } }, data: change });
await db.auditLog.createMany({ data: ids.map((offerId) => ({ shopId, offerId, staffUser: staffUser || null, field: action, newValue: "bulk" })) });
} else if (action === "delete") {
await db.offer.deleteMany({ where: { shopId, id: { in: ids }, active: false } }); // only archived offers
}
}
/** Moves an offer one step up/down in priority; renumbers sequences 10, 20, 30… */
export async function moveOffer(shopId: string, id: string, direction: "up" | "down") {
const offers = await db.offer.findMany({ where: { shopId, active: true }, orderBy: [{ sequence: "asc" }, { createdAt: "desc" }], select: { id: true } });
const i = offers.findIndex((o) => o.id === id);
const j = direction === "up" ? i - 1 : i + 1;
if (i < 0 || j < 0 || j >= offers.length) return;
[offers[i], offers[j]] = [offers[j], offers[i]];
await db.$transaction(offers.map((o, k) => db.offer.update({ where: { id: o.id }, data: { sequence: (k + 1) * 10 } })));
}
/** Plan downgrade: keep all offers, unpublish the lowest-priority ones above the limit (§18.2). */
export async function enforcePlanOnPublished(shopId: string, limit: number, allowedTypes: string[]) {
const published = await db.offer.findMany({
where: { shopId, active: true, published: true },
orderBy: [{ sequence: "asc" }, { startsAt: "desc" }, { createdAt: "desc" }],
select: { id: true, type: true, endsAt: true },
});
const now = new Date();
const live = published.filter((o) => !o.endsAt || o.endsAt > now);
const toUnpublish: string[] = [];
let kept = 0;
for (const o of live) {
if (!allowedTypes.includes(o.type) || kept >= limit) toUnpublish.push(o.id);
else kept += 1;
}
if (toUnpublish.length) {
await db.offer.updateMany({ where: { id: { in: toUnpublish } }, data: { published: false, limitedByPlan: true } });
}
return toUnpublish.length;
}