← Files WixARCHIVED FILE

skills/wix-headless-templates/donations/seed/seed-donations.mjs

12.9 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// Donations seed — a BUILD-TIME script, never shipped in the app. Run from the project root
// (where wix.config.json lives) with a plan file:
//
//   node <SKILL_ROOT>/templates/donations/seed/seed-donations.mjs plan.json
//
// It mints its own site token via the Wix CLI, sets the site currency when the plan names one,
// installs the eCom platform and the Wix Donations app if needed, creates the campaigns (one create
// call per campaign), verifies each is readable, then imports and attaches cover images in a second
// pass. Prints a JSON result to stdout.
//
// Plan shape (see SEED.md):
//   { "currency"?: "USD",
//     "campaigns": [{ "name" (1-65 chars),
//                     "frequencies"?: ["ONE_TIME"|"WEEK"|"MONTH"|"YEAR"] (1-4, default ["ONE_TIME"]),
//                     "presets"?: [{ "amount" (decimal string), "impact"? (≤200 chars) }] (≤20),
//                     "customAmount"?: { "enabled", "min"?, "max"? },
//                     "goal"?: { "target", "endDate"?, "acceptAfterGoal"?, "acceptAfterEndDate"? },
//                     "comments"?: false, "askCoverFee"?: false,
//                     "imageUrl"? | "imagePath"? | "imagePrompt"? }] }
//
// Seeding is ADDITIVE and idempotent — a non-archived campaign with the same name is reused, never
// duplicated; nothing is deleted or overwritten. Unexpected shapes → read the live API reference;
// every call below carries a docs: line with its reference page.
import { setSiteCurrency } from "../../shared/seed/site.mjs";
import { resolveItemImages } from "../../shared/seed/images.mjs";
import { seedSiteId } from "../../shared/seed/site-context.mjs";
import { wixToken } from "../../shared/seed/wix-cli.mjs";
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";

const API = "https://www.wixapis.com";
export const DONATIONS_APP_ID = "333b456e-dd48-4d6b-b32b-9fd48d74e163";
export const ECOM_PLATFORM_APP_ID = "1380b703-ce81-ff05-f115-39571d94dfcd"; // Checkout & Orders — a donation is an eCom checkout
const CAMPAIGNS = "/donation-campaigns/v2/donation-campaigns";
const D = "https://dev.wix.com/docs/api-reference/business-solutions/donations/donation-campaigns";
const FREQUENCIES = ["ONE_TIME", "WEEK", "MONTH", "YEAR"];

export function makeCtx({ cwd = process.cwd() } = {}) {
  // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
  // a seed there unless --allow-parent is passed after the user confirmed).
  const siteId = seedSiteId({ cwd, argv: process.argv });
  const token = wixToken(siteId, cwd);
  return { token, siteId };
}

async function req(ctx, path, { method = "POST", body } = {}) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${ctx.token}`,
      "wix-site-id": ctx.siteId,
      "Content-Type": "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    const e = new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 400)}`);
    e.status = res.status;
    throw e;
  }
  return json;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const money = (v) => ({ amount: String(v) }); // amounts are decimal STRINGS in the site currency

// ---- plan → create body ----------------------------------------------------------------------------

// The rules the API enforces (the create call rejects otherwise): name 1-65 chars; 1-4 frequencies;
// ≤20 presets, each with a REQUIRED client-supplied GUID id and a ≤200-char description; when the
// custom amount is off at least one preset. Defaults when the plan says nothing: one-time only, no
// note, no fee prompt; a campaign with no presets gets the custom amount enabled with no limits.
export function buildCampaign(c) {
  if (!c.name || c.name.length > 65) throw new Error(`campaign name must be 1-65 chars: "${c.name ?? ""}"`);
  const frequencies = (c.frequencies?.length ? c.frequencies : ["ONE_TIME"]).map((f) => String(f).toUpperCase());
  for (const f of frequencies) if (!FREQUENCIES.includes(f)) throw new Error(`"${c.name}": unknown frequency "${f}" — one of ${FREQUENCIES.join(", ")}`);
  if (frequencies.length > 4) throw new Error(`"${c.name}": at most 4 frequencies`);
  const presets = (c.presets ?? []).map((p) => {
    const amount = typeof p === "object" ? p.amount : p;
    const impact = typeof p === "object" ? p.impact ?? p.description : undefined;
    if (impact && impact.length > 200) throw new Error(`"${c.name}": impact text over 200 chars`);
    return { id: randomUUID(), price: money(amount), ...(impact ? { description: impact } : {}) };
  });
  if (presets.length > 20) throw new Error(`"${c.name}": at most 20 preset amounts`);
  const custom = c.customAmount ?? (presets.length ? { enabled: false } : { enabled: true });
  const customEnabled = custom.enabled !== false;
  if (!customEnabled && !presets.length) throw new Error(`"${c.name}": needs a preset amount or customAmount.enabled`);
  const body = {
    name: c.name,
    donationFrequencies: frequencies,
    predefinedDonationAmounts: presets,
    customAmountEnabled: customEnabled,
    commentsEnabled: c.comments === true,
    askDonorCoverFee: c.askCoverFee === true,
  };
  if (customEnabled && (custom.min != null || custom.max != null)) {
    body.customAmountOptions = {
      ...(custom.min != null ? { minimum: money(custom.min) } : {}),
      ...(custom.max != null ? { maximum: money(custom.max) } : {}),
    };
  }
  if (c.goal?.target != null) {
    body.campaignGoal = {
      targetAmount: money(c.goal.target),
      ...(c.goal.endDate ? { endDate: c.goal.endDate } : {}),
      ...(c.goal.acceptAfterGoal != null ? { acceptDonationsAfterGoal: c.goal.acceptAfterGoal } : {}),
      ...(c.goal.acceptAfterEndDate != null ? { acceptDonationsAfterEndDate: c.goal.acceptAfterEndDate } : {}),
    };
  }
  return body;
}

// ---- operations ------------------------------------------------------------------------------------

// Idempotent: re-installing an already-installed app returns 200. eCom first — a donation is an eCom
// checkout; whether installing Donations provisions eCom by itself is not verified, so both go in.
// docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
export async function installApp(ctx, appDefId) {
  try {
    await req(ctx, "/apps-installer-service/v1/app-instance/install", { body: {
      tenant: { tenantType: "SITE", id: ctx.siteId },
      appInstance: { appDefId, enabled: true },
    } });
    return true;
  } catch (e) {
    return `not installed here (${String(e.message).slice(0, 160)}) — the owner can add Wix Donations from the dashboard's App Market`;
  }
}

export async function installDonationsApps(ctx) {
  return { ecom: await installApp(ctx, ECOM_PLATFORM_APP_ID), donations: await installApp(ctx, DONATIONS_APP_ID) };
}

// A non-archived campaign with this exact name, when one exists (`name` is filterable).
// docs: https://dev.wix.com/docs/api-reference/business-solutions/donations/donation-campaigns/query-donation-campaigns.md
export async function findCampaignByName(ctx, name) {
  const r = await req(ctx, `${CAMPAIGNS}/query`, { body: { query: { filter: { name, archived: false }, cursorPaging: { limit: 1 } } } });
  return r.donationCampaigns?.[0] ?? null;
}

// docs: https://dev.wix.com/docs/api-reference/business-solutions/donations/donation-campaigns/get-donation-campaign.md
export async function getCampaign(ctx, id) {
  return (await req(ctx, `${CAMPAIGNS}/${id}`, { method: "GET" })).donationCampaign;
}

// Create, then verify the campaign reads back (the read side can lag the write by a moment — retry
// the GET a few times, never the POST). Reuses an existing campaign of the same name.
// docs: https://dev.wix.com/docs/api-reference/business-solutions/donations/donation-campaigns/create-donation-campaign.md
export async function createCampaign(ctx, plan) {
  const existing = await findCampaignByName(ctx, plan.name).catch(() => null);
  if (existing?.id) return { id: existing.id, name: existing.name, status: existing.status, created: false };
  const r = await req(ctx, CAMPAIGNS, { body: { donationCampaign: buildCampaign(plan) } });
  const id = r.donationCampaign?.id;
  if (!id) throw new Error(`Campaign "${plan.name}" was not created — no id returned.`);
  let verified = null;
  for (let attempt = 0; attempt < 4 && !verified; attempt++) {
    verified = await getCampaign(ctx, id).catch(() => null);
    if (!verified) await sleep(1000 * (attempt + 1));
  }
  return { id, name: plan.name, status: verified?.status ?? r.donationCampaign?.status ?? null, created: true, verified: !!verified };
}

export async function createCampaigns(ctx, campaigns) {
  const out = [];
  for (const c of campaigns) out.push({ ...(await createCampaign(ctx, c)), plan: c });
  return out;
}

// Attach a Wix Media file as the cover image. The Image object's shape follows the SDK's transforms
// ({ id, url, height, width, altText }); whether the update needs a field mask is not verified live,
// so a plain PATCH is tried first and one with an explicit mask second. Revision comes from a fresh
// GET (an optimistic-locking field).
// docs: https://dev.wix.com/docs/api-reference/business-solutions/donations/donation-campaigns/update-donation-campaign.md
export async function setCampaignCoverImage(ctx, { campaignId, id, url, height = 1024, width = 1024, altText }) {
  const current = await getCampaign(ctx, campaignId);
  const donationCampaign = { id: campaignId, revision: current.revision, coverImage: { id, url, height, width, ...(altText ? { altText } : {}) } };
  try {
    return await req(ctx, `${CAMPAIGNS}/${campaignId}`, { method: "PATCH", body: { donationCampaign } });
  } catch (first) {
    if (first.status !== 400) throw first;
    return req(ctx, `${CAMPAIGNS}/${campaignId}`, { method: "PATCH", body: { donationCampaign, fieldMask: { paths: ["coverImage"] } } });
  }
}

/**
 * ONE-CALL seed: site currency (if named) → install eCom + Donations → one create per campaign
 * (verified) → images in a second pass. The default path.
 */
export async function setupDonations(ctx, { campaigns = [], currency } = {}) {
  if (!campaigns.length) throw new Error("plan.campaigns is empty — nothing to seed");
  // Before any campaign exists: every amount is stored in the site currency.
  if (currency) await setSiteCurrency(ctx, currency);
  const installed = await installDonationsApps(ctx);
  const created = await createCampaigns(ctx, campaigns);

  // Pass 2 — images: resolve (upload a local file / import by url / generate by prompt) in one
  // parallel wave, then attach. Failures leave the campaign text-only; the exit never depends on them.
  const wanted = created.filter((c) => c.created && (c.plan.imageUrl || c.plan.imagePath || c.plan.imagePrompt));
  const files = await resolveItemImages(ctx, wanted.map((c) => ({
    url: c.plan.imageUrl,
    path: c.plan.imagePath,
    prompt: c.plan.imagePrompt,
    displayName: `${c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "campaign"}.png`,
  })));
  let imagesAttached = 0;
  const imageErrors = [];
  for (let i = 0; i < wanted.length; i++) {
    if (!files[i]) continue;
    try {
      await setCampaignCoverImage(ctx, { campaignId: wanted[i].id, id: files[i].id, url: files[i].url, altText: wanted[i].name });
      imagesAttached++;
    } catch (e) {
      imageErrors.push(`${wanted[i].name}: ${String(e.message).slice(0, 200)}`); // never block on an image
    }
  }

  const recurring = created.some((c) => (c.plan.frequencies ?? []).some((f) => String(f).toUpperCase() !== "ONE_TIME"));
  return {
    installed,
    campaigns: created.map(({ id, name, status, created: isNew, verified }) => ({ id, name, status, created: isNew, ...(isNew ? { verified } : {}) })),
    imagesAttached,
    ...(imageErrors.length ? { imageErrors } : {}),
    // Completing a donation needs a premium plan + a connected payment method in the dashboard —
    // not a seeding failure; surface it to the owner.
    notes: [
      "Taking real donations requires a premium plan and a connected payment method in the dashboard; until then the hosted checkout refuses the payment.",
      ...(recurring ? ["Recurring frequencies (WEEK/MONTH/YEAR) also need a payment provider that supports recurring payments."] : []),
    ],
  };
}

// ---- CLI entry ----------------------------------------------------------------------------------

const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop());
if (invokedDirectly) {
  const planPath = process.argv[2];
  if (!planPath) {
    console.error("usage: node seed-donations.mjs <plan.json>   (run from the project root)");
    process.exit(1);
  }
  const plan = JSON.parse(readFileSync(planPath, "utf8"));
  const ctx = makeCtx();
  setupDonations(ctx, plan)
    .then((result) => console.log(JSON.stringify(result, null, 2)))
    .catch((e) => {
      console.error(e.message);
      process.exit(1);
    });
}

SHA-256: 3b19ede5664f3335c32f46452eda73df1c6767e47526d7b9abaf240fc7ee1ce1