Setting the file. One moment.
Seed Donations · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 16.269
Position 269 of 828
Type JavaScript
Size 13 KB
Lines 252 donations/seed/ seed-donations.mjs
JavaScript · 252 lines · 13 KB
15 // "presets"?: [{ "amount" (decimal string), "impact"? (≤200 chars) }] (≤20),
16 // "customAmount"?: { "enabled", "min"?, "max"? },
17 // "goal"?: { "target", "endDate"?, "acceptAfterGoal"?, "acceptAfterEndDate"? },
18 // "comments"?: false, "askCoverFee"?: false,
19 // "imageMediaId"? | "imageUrl"? | "imagePath"? | "imagePrompt"? }] }
20 //
21 // Seeding is ADDITIVE and idempotent — a non-archived campaign with the same name is reused, never
22 // duplicated; nothing is deleted or overwritten. Unexpected shapes → read the live API reference;
23 // every call below carries a docs: line with its reference page.
24 import { setSiteCurrency } from "../../shared/seed/site.mjs" ;
25 import { resolveItemImages } from "../../shared/seed/images.mjs" ;
26 import { seedSiteId } from "../../shared/seed/site-context.mjs" ;
27 import { wixToken } from "../../shared/seed/wix-cli.mjs" ;
28 import { isMain } from "../../shared/seed/main.mjs" ;
29 import { readFileSync } from "node:fs" ;
30 import { randomUUID } from "node:crypto" ;
31
32 const API = "https://www.wixapis.com" ;
33 export const DONATIONS_APP_ID = "333b456e-dd48-4d6b-b32b-9fd48d74e163" ;
34 export const ECOM_PLATFORM_APP_ID = "1380b703-ce81-ff05-f115-39571d94dfcd" ; // Checkout & Orders — a donation is an eCom checkout
35 const CAMPAIGNS = "/donation-campaigns/v2/donation-campaigns" ;
36 const D = "https://dev.wix.com/docs/api-reference/business-solutions/donations/donation-campaigns" ;
37 const FREQUENCIES = [ "ONE_TIME" , "WEEK" , "MONTH" , "YEAR" ];
38
39 export function makeCtx ({ cwd = process. cwd () } = {}) {
40 // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
41 // a seed there unless --allow-parent is passed after the user confirmed).
42 const siteId = seedSiteId ({ cwd, argv: process.argv });
43 const token = wixToken (siteId, cwd);
44 return { token, siteId };
45 }
46
47 async function req ( ctx , path , { method = "POST" , body } = {}) {
48 const res = await fetch ( API + path, {
49 method,
50 headers: {
51 Authorization: `Bearer ${ ctx . token }` ,
52 "wix-site-id" : ctx.siteId,
53 "Content-Type" : "application/json" ,
54 },
55 body: body ? JSON . stringify (body) : undefined ,
56 });
57 const json = await res. json (). catch (() => ({}));
58 if ( ! res.ok) {
59 const e = new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
60 e.status = res.status;
61 throw e;
62 }
63 return json;
64 }
65
66 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
67 const money = ( v ) => ({ amount: String (v) }); // amounts are decimal STRINGS in the site currency
68
69 // ---- plan → create body ----------------------------------------------------------------------------
70
71 // The rules the API enforces (the create call rejects otherwise): name 1-65 chars; 1-4 frequencies;
72 // ≤20 presets, each with a REQUIRED client-supplied GUID id and a ≤200-char description; when the
73 // custom amount is off at least one preset. Defaults when the plan says nothing: one-time only, no
74 // note, no fee prompt; a campaign with no presets gets the custom amount enabled with no limits.
75 export function buildCampaign ( c ) {
76 if ( ! c.name || c.name. length > 65 ) throw new Error ( `campaign name must be 1-65 chars: "${ c . name ?? ""}"` );
77 const frequencies = (c.frequencies?. length ? c.frequencies : [ "ONE_TIME" ]). map (( f ) => String (f). toUpperCase ());
78 for ( const f of frequencies) if ( ! FREQUENCIES . includes (f)) throw new Error ( `"${ c . name }": unknown frequency "${ f }" — one of ${ FREQUENCIES . join ( ", " ) }` );
79 if (frequencies. length > 4 ) throw new Error ( `"${ c . name }": at most 4 frequencies` );
80 const presets = (c.presets ?? []). map (( p ) => {
81 const amount = typeof p === "object" ? p.amount : p;
82 const impact = typeof p === "object" ? p.impact ?? p.description : undefined ;
83 if (impact && impact. length > 200 ) throw new Error ( `"${ c . name }": impact text over 200 chars` );
84 return { id: randomUUID (), price: money (amount), ... (impact ? { description: impact } : {}) };
85 });
86 if (presets. length > 20 ) throw new Error ( `"${ c . name }": at most 20 preset amounts` );
87 const custom = c.customAmount ?? (presets. length ? { enabled: false } : { enabled: true });
88 const customEnabled = custom.enabled !== false ;
89 if ( ! customEnabled && ! presets. length ) throw new Error ( `"${ c . name }": needs a preset amount or customAmount.enabled` );
90 const body = {
91 name: c.name,
92 donationFrequencies: frequencies,
93 predefinedDonationAmounts: presets,
94 customAmountEnabled: customEnabled,
95 commentsEnabled: c.comments === true ,
96 askDonorCoverFee: c.askCoverFee === true ,
97 };
98 if (customEnabled && (custom.min != null || custom.max != null )) {
99 body.customAmountOptions = {
100 ... (custom.min != null ? { minimum: money (custom.min) } : {}),
101 ... (custom.max != null ? { maximum: money (custom.max) } : {}),
102 };
103 }
104 if (c.goal?.target != null ) {
105 body.campaignGoal = {
106 targetAmount: money (c.goal.target),
107 ... (c.goal.endDate ? { endDate: c.goal.endDate } : {}),
108 ... (c.goal.acceptAfterGoal != null ? { acceptDonationsAfterGoal: c.goal.acceptAfterGoal } : {}),
109 ... (c.goal.acceptAfterEndDate != null ? { acceptDonationsAfterEndDate: c.goal.acceptAfterEndDate } : {}),
110 };
111 }
112 return body;
113 }
114
115 // ---- operations ------------------------------------------------------------------------------------
116
117 // Idempotent: re-installing an already-installed app returns 200. eCom first — a donation is an eCom
118 // checkout; whether installing Donations provisions eCom by itself is not verified, so both go in.
119 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
120 export async function installApp ( ctx , appDefId ) {
121 try {
122 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
123 tenant: { tenantType: "SITE" , id: ctx.siteId },
124 appInstance: { appDefId, enabled: true },
125 } });
126 return true ;
127 } catch (e) {
128 return `not installed here (${ String ( e . message ). slice ( 0 , 160 ) }) — the owner can add Wix Donations from the dashboard's App Market` ;
129 }
130 }
131
132 export async function installDonationsApps ( ctx ) {
133 return { ecom: await installApp (ctx, ECOM_PLATFORM_APP_ID ), donations: await installApp (ctx, DONATIONS_APP_ID ) };
134 }
135
136 // A non-archived campaign with this exact name, when one exists (`name` is filterable).
137 // docs: https://dev.wix.com/docs/api-reference/business-solutions/donations/donation-campaigns/query-donation-campaigns.md
138 export async function findCampaignByName ( ctx , name ) {
139 const r = await req (ctx, `${ CAMPAIGNS }/query` , { body: { query: { filter: { name, archived: false }, cursorPaging: { limit: 1 } } } });
140 return r.donationCampaigns?.[ 0 ] ?? null ;
141 }
142
143 // docs: https://dev.wix.com/docs/api-reference/business-solutions/donations/donation-campaigns/get-donation-campaign.md
144 export async function getCampaign ( ctx , id ) {
145 return ( await req (ctx, `${ CAMPAIGNS }/${ id }` , { method: "GET" })).donationCampaign;
146 }
147
148 // Create, then verify the campaign reads back (the read side can lag the write by a moment — retry
149 // the GET a few times, never the POST). Reuses an existing campaign of the same name.
150 // docs: https://dev.wix.com/docs/api-reference/business-solutions/donations/donation-campaigns/create-donation-campaign.md
151 export async function createCampaign ( ctx , plan ) {
152 const existing = await findCampaignByName (ctx, plan.name). catch (() => null );
153 if (existing?.id) return { id: existing.id, name: existing.name, status: existing.status, created: false };
154 const r = await req (ctx, CAMPAIGNS , { body: { donationCampaign: buildCampaign (plan) } });
155 const id = r.donationCampaign?.id;
156 if ( ! id) throw new Error ( `Campaign "${ plan . name }" was not created — no id returned.` );
157 let verified = null ;
158 for ( let attempt = 0 ; attempt < 4 && ! verified; attempt ++ ) {
159 verified = await getCampaign (ctx, id). catch (() => null );
160 if ( ! verified) await sleep ( 1000 * (attempt + 1 ));
161 }
162 return { id, name: plan.name, status: verified?.status ?? r.donationCampaign?.status ?? null , created: true , verified: !! verified };
163 }
164
165 export async function createCampaigns ( ctx , campaigns ) {
166 const out = [];
167 for ( const c of campaigns) out. push ({ ... ( await createCampaign (ctx, c)), plan: c });
168 return out;
169 }
170
171 // Attach a Wix Media file as the cover image. The Image object's shape follows the SDK's transforms
172 // ({ id, url, height, width, altText }); whether the update needs a field mask is not verified live,
173 // so a plain PATCH is tried first and one with an explicit mask second. Revision comes from a fresh
174 // GET (an optimistic-locking field).
175 // docs: https://dev.wix.com/docs/api-reference/business-solutions/donations/donation-campaigns/update-donation-campaign.md
176 export async function setCampaignCoverImage ( ctx , { campaignId , id , url , height = 1024 , width = 1024 , altText }) {
177 const current = await getCampaign (ctx, campaignId);
178 const donationCampaign = { id: campaignId, revision: current.revision, coverImage: { id, url, height, width, ... (altText ? { altText } : {}) } };
179 try {
180 return await req (ctx, `${ CAMPAIGNS }/${ campaignId }` , { method: "PATCH" , body: { donationCampaign } });
181 } catch (first) {
182 if (first.status !== 400 ) throw first;
183 return req (ctx, `${ CAMPAIGNS }/${ campaignId }` , { method: "PATCH" , body: { donationCampaign, fieldMask: { paths: [ "coverImage" ] } } });
184 }
185 }
186
187 /**
188 * ONE-CALL seed: site currency (if named) → install eCom + Donations → one create per campaign
189 * (verified) → images in a second pass. The default path.
190 */
191 export async function setupDonations ( ctx , { campaigns = [], currency } = {}) {
192 if ( ! campaigns. length ) throw new Error ( "plan.campaigns is empty — nothing to seed" );
193 // Before any campaign exists: every amount is stored in the site currency.
194 if (currency) await setSiteCurrency (ctx, currency);
195 const installed = await installDonationsApps (ctx);
196 const created = await createCampaigns (ctx, campaigns);
197
198 // Pass 2 — images: resolve (upload a local file / import by url / generate by prompt) in one
199 // parallel wave, then attach. Failures leave the campaign text-only; the exit never depends on them.
200 const wanted = created. filter (( c ) => c.created && (c.plan.imageMediaId || c.plan.imageUrl || c.plan.imagePath || c.plan.imagePrompt));
201 const files = await resolveItemImages (ctx, wanted. map (( c ) => ({
202 mediaId: c.plan.imageMediaId,
203 url: c.plan.imageUrl,
204 path: c.plan.imagePath,
205 prompt: c.plan.imagePrompt,
206 displayName: `${ c . name . toLowerCase (). replace ( / [ ^ a-z0-9] + / g , "-" ). replace ( / ^ - | - $ / g , "" ) || "campaign"}.png` ,
207 })));
208 let imagesAttached = 0 ;
209 const imageErrors = [];
210 for ( let i = 0 ; i < wanted. length ; i ++ ) {
211 if ( ! files[i]) continue ;
212 try {
213 await setCampaignCoverImage (ctx, { campaignId: wanted[i].id, id: files[i].id, url: files[i].url, altText: wanted[i].name });
214 imagesAttached ++ ;
215 } catch (e) {
216 imageErrors. push ( `${ wanted [ i ]. name }: ${ String ( e . message ). slice ( 0 , 200 ) }` ); // never block on an image
217 }
218 }
219
220 const recurring = created. some (( c ) => (c.plan.frequencies ?? []). some (( f ) => String (f). toUpperCase () !== "ONE_TIME" ));
221 return {
222 installed,
223 campaigns: created. map (({ id , name , status , created : isNew , verified }) => ({ id, name, status, created: isNew, ... (isNew ? { verified } : {}) })),
224 imagesAttached,
225 ... (imageErrors. length ? { imageErrors } : {}),
226 // Completing a donation needs a premium plan + a connected payment method in the dashboard —
227 // not a seeding failure; surface it to the owner.
228 notes: [
229 "Taking real donations requires a premium plan and a connected payment method in the dashboard; until then the hosted checkout refuses the payment." ,
230 ... (recurring ? [ "Recurring frequencies (WEEK/MONTH/YEAR) also need a payment provider that supports recurring payments." ] : []),
231 ],
232 };
233 }
234
235 // ---- CLI entry ----------------------------------------------------------------------------------
236
237 const invokedDirectly = isMain ( import . meta .url);
238 if (invokedDirectly) {
239 const planPath = process.argv[ 2 ];
240 if ( ! planPath) {
241 console. error ( "usage: node seed-donations.mjs <plan.json> (run from the project root)" );
242 process. exit ( 1 );
243 }
244 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
245 const ctx = makeCtx ();
246 setupDonations (ctx, plan)
247 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
248 . catch (( e ) => {
249 console. error (e.message);
250 process. exit ( 1 );
251 });
252 }