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