Setting the file. One moment.
Seed Bookings · Wix Vibe Headless · wix/skills · Skills Docs
ContentsBack to the top of the page 22.10
Post Detail
This file
Number 22.31
Position 31 of 145
Type JavaScript
Size 16 KB
Lines 333 references/bookings/seed/ seed-bookings.cjs
JavaScript · 333 lines · 16 KB
14
// await seed.installBookingsApp(ctx); // if the site doesn't have Wix Bookings yet
15 //
16 // // ORDER MATTERS: resolve a staff resource (STEP 1) and a category (STEP 2) BEFORE services (STEP 3).
17 // const staff = await seed.queryStaffWithRetry(ctx); // polls: fresh install provisions the owner async
18 // const resourceId = staff[0].resourceId; // NB: resourceId, NOT staff id
19 // const cats = await seed.createCategories(ctx, ["Our Services"]);
20 // const services = await seed.createServices(ctx, [
21 // { type: "APPOINTMENT", name: "Consultation", description: "…", tagLine: "…",
22 // price: 75, duration: 60, categoryId: cats[0].id, staffMemberIds: [resourceId] },
23 // { type: "CLASS", name: "Morning Yoga", description: "…", capacity: 20,
24 // price: 20, categoryId: cats[0].id },
25 // ]);
26 // // STEP 4 (CLASS only): needs each class's returned scheduleId.
27 // await seed.scheduleClassSessions(ctx, services.filter(s => s.type === "CLASS").map(s => ({
28 // scheduleId: s.scheduleId, resourceId, start: "2026-08-10T09:00:00", end: "2026-08-10T10:00:00", capacity: 20,
29 // })));
30 // // optional — import the image url to Wix Media first (bookings binds by file id), then patch (revision-checked).
31 // // const file = await seed.importImage(ctx, imageUrl); // → { id, url } (Wix Media file id + wixstatic url)
32 // // await seed.attachServiceImage(ctx, { serviceId: s.id, revision: s.revision, image: { id: file.id, url: file.url, width: 1024, height: 1024 } });
33 //
34 // If any call fails with a shape the caller didn't expect, or you need an operation this module
35 // doesn't cover, fall back to the documentation skill available in your environment (search + read the live Wix API reference) —
36 // never guess. Source recipe (authoritative): wix-headless/references/inline-recipes/setup-bookings.md.
37
38 const API = "https://www.wixapis.com" ;
39 // Wix Bookings app id (from the recipe's "API surfaces" note).
40 const BOOKINGS_APP_ID = "13d21c63-b5ec-5912-8397-c3a5ddb27a97" ;
41
42 async function req ( ctx , path , { method = "POST" , body } = {}) {
43 const res = await fetch ( API + path, {
44 method,
45 headers: {
46 Authorization: `Bearer ${ ctx . token }` ,
47 "wix-site-id" : ctx.siteId,
48 "Content-Type" : "application/json" ,
49 },
50 body: body ? JSON . stringify (body) : undefined ,
51 });
52 const json = await res. json (). catch (() => ({}));
53 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
54 return json;
55 }
56
57 // name -> url slug fallback when item.mainSlug.name is absent (lowercase, non-alphanumerics -> hyphens, dedupe)
58 function slugify ( name ) {
59 return String (name || "" )
60 . toLowerCase ()
61 . replace ( / [ ^ a-z0-9] + / g , "-" )
62 . replace ( / ^ - +| - +$ / g , "" );
63 }
64
65 // plain service -> flat Services V2 create object. price omitted / free:true -> NO_FEE.
66 // sessionDurations is APPOINTMENT-only; staffMemberIds is required-non-empty for APPOINTMENT, omitted for CLASS.
67 function buildService ( s ) {
68 const isAppointment = s.type === "APPOINTMENT" ;
69 const free = s.free === true || s.price == null ;
70 const out = {
71 type: s.type,
72 name: s.name,
73 description: s.description,
74 tagLine: s.tagLine,
75 defaultCapacity: s.capacity ?? (isAppointment ? 1 : undefined ), // required for ALL types
76 onlineBooking: { enabled: true , requireManualApproval: false , allowMultipleRequests: false },
77 payment: free
78 ? { rateType: "NO_FEE" , options: { online: false , inPerson: true } }
79 : {
80 rateType: "FIXED" ,
81 fixed: { price: { value: String (s.price), currency: s.currency ?? "USD" } }, // value is a STRING; site currency wins
82 options: { online: true , inPerson: false },
83 },
84 category: { id: s.categoryId }, // mandatory for live-site visibility
85 locations: [{ type: "BUSINESS" }], // never OWNER_BUSINESS on the services endpoint
86 };
87 if (isAppointment) {
88 out.schedule = { availabilityConstraints: { sessionDurations: [s.duration] } }; // APPOINTMENT only, minutes
89 out.staffMemberIds = s.staffMemberIds; // resourceId(s) from STEP 1 — non-empty or MISSING_APPOINTMENT_RESOURCES
90 }
91 return out;
92 }
93
94 // ---- exported operations ----
95
96 async function installBookingsApp ( ctx ) {
97 return req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
98 tenant: { tenantType: "SITE" , id: ctx.siteId },
99 appInstance: { appDefId: BOOKINGS_APP_ID , enabled: true },
100 } });
101 }
102
103 // The default "Business Owner" is a RESOURCE, not a service — it won't appear here.
104 async function listServices ( ctx ) {
105 const r = await req (ctx, "/bookings/v2/services/query" , { body: { query: { paging: { limit: 100 } } } });
106 return (r.services ?? []). map (( s ) => ({ id: s.id, name: s.name }));
107 }
108
109 // STEP 1: resolve staff resources. Returns [{ resourceId, id, name }].
110 // ⚠️ staffMemberIds takes resourceId, NOT the staff id — read staffMember.resourceId.
111 async function queryStaff ( ctx ) {
112 const r = await req (ctx, "/bookings/v1/staff-members/query" , {
113 body: { query: {}, fields: [ "RESOURCE_DETAILS" ] },
114 });
115 return (r.staffMembers ?? []). map (( m ) => ({ resourceId: m.resourceId, id: m.id, name: m.name }));
116 }
117
118 // installBookingsApp → provisioning is async (Wix signals completion via the App-Instance-Installed
119 // webhook, which an exec script can't receive). The default "Business Owner" resource isn't queryable
120 // in the same tick, so queryStaff returns [] (or throws "Business schedule not found") until it lands.
121 // Poll so the one-call path resolves the owner instead of failing APPOINTMENT services with
122 // MISSING_APPOINTMENT_RESOURCES. Already-provisioned sites return on the first try (no added latency).
123 async function queryStaffWithRetry ( ctx , { tries = 10 , delayMs = 2000 } = {}) {
124 let lastErr;
125 for ( let i = 0 ; i < tries; i ++ ) {
126 try { const s = await queryStaff (ctx); if (s. length ) return s; }
127 catch (e) { lastErr = e; }
128 if (i < tries - 1 ) await new Promise (( r ) => setTimeout (r, delayMs));
129 }
130 if (lastErr) throw lastErr; // never provisioned → surface the real error rather than a silent []
131 return [];
132 }
133
134 // STEP 1 (optional): create named staff when the request names stylists/providers. staff: [{ name, description }].
135 // ⚠️ Omit email/phone unless you have a real value — V1 rejects empty strings. Returns [{ resourceId, id, name }].
136 async function createStaff ( ctx , staff ) {
137 const out = [];
138 for ( const m of staff) {
139 const body = { staffMember: { name: m.name, description: m.description } };
140 const r = await req (ctx, "/bookings/v1/staff-members" , { body });
141 out. push ({ resourceId: r.staffMember?.resourceId, id: r.staffMember?.id, name: r.staffMember?.name });
142 }
143 return out;
144 }
145
146 // STEP 2: query existing categories (to reuse one created earlier this run). Returns [{ id, name }].
147 async function queryCategories ( ctx ) {
148 const r = await req (ctx, "/bookings/v2/categories/query" , { body: { query: {} } });
149 return (r.categories ?? []). map (( c ) => ({ id: c.id, name: c.name }));
150 }
151
152 // STEP 2: create categories — every service needs a category.id or it's invisible on the live site.
153 // Independent (no shared revision, unlike Stores categories); looped here one call per category.
154 async function createCategories ( ctx , names ) {
155 // Reuse an existing same-named category instead of creating a duplicate. Bookings categories aren't
156 // unique-by-name, so a re-run (or a partial-failure retry — categories are created before services)
157 // would otherwise pile up dupes. Keeps the seed additive-but-idempotent.
158 const byName = new Map (( await queryCategories (ctx)). map (( c ) => [c.name, c]));
159 const out = [];
160 for ( const name of names) {
161 let cat = byName. get (name);
162 if ( ! cat) {
163 const r = await req (ctx, "/bookings/v2/categories" , { body: { category: { name } } });
164 cat = { id: r.category?.id, name };
165 byName. set (name, cat);
166 }
167 out. push (cat);
168 }
169 return out;
170 }
171
172 /**
173 * STEP 3: bulk-create services (up to 100), one call. APPOINTMENT and CLASS may be mixed.
174 * MUST run AFTER staff (STEP 1) and category (STEP 2) are resolved.
175 * @param services [{ type:"APPOINTMENT"|"CLASS", name, description, tagLine?, categoryId,
176 * price?, currency?, free?, duration?(APPOINTMENT minutes), capacity?, staffMemberIds?(resourceIds, APPOINTMENT) }]
177 * @returns [{ id, slug, revision, type, scheduleId, index, success, error }]
178 * scheduleId feeds scheduleClassSessions (CLASS); revision feeds attachServiceImage.
179 * Retry only the items where success===false, ONCE, with the same body — don't loop, don't re-create successes.
180 */
181 async function createServices ( ctx , services ) {
182 const body = { services: services. map (buildService), returnEntity: true };
183 const r = await req (ctx, "/bookings/v2/bulk/services/create" , { body });
184 return (r.results ?? []). map (( res , i ) => {
185 const item = res.item ?? {};
186 return {
187 id: item.id ?? res.itemMetadata?.id,
188 slug: item.mainSlug?.name ?? slugify (item.name ?? services[i]?.name),
189 revision: item.revision,
190 type: item.type ?? services[i]?.type,
191 scheduleId: item.schedule?.id,
192 index: res.itemMetadata?.originalIndex ?? i,
193 success: res.itemMetadata?.success ?? false ,
194 error: res.itemMetadata?.error,
195 };
196 });
197 }
198
199 /**
200 * STEP 4 (CLASS only): schedule sessions in one bulk Calendar-Events-V3 call. Skip for APPOINTMENT.
201 * @param sessions [{ scheduleId(CLASS item.schedule.id), resourceId(from STEP 1), start, end, capacity? }]
202 * start/end are LOCAL wall-clock "YYYY-MM-DDThh:mm:ss" (no Z), today-or-future.
203 * @returns [{ id, index, success, error }] — events send no returnEntity, so ids come from itemMetadata only.
204 * Retry only failed events once.
205 */
206 async function scheduleClassSessions ( ctx , sessions ) {
207 const body = {
208 events: sessions. map (( s ) => ({
209 event: {
210 scheduleId: s.scheduleId,
211 type: "CLASS" ,
212 start: { localDate: s.start },
213 end: { localDate: s.end },
214 resources: [{ id: s.resourceId, permissionRole: "WRITER" }], // non-empty + WRITER, else UNKNOWN_ROLE 400
215 ... (s.capacity != null ? { totalCapacity: s.capacity } : {}),
216 },
217 })),
218 };
219 const r = await req (ctx, "/calendar/v3/bulk/events/create" , { body });
220 return (r.results ?? []). map (( res , i ) => ({
221 id: res.itemMetadata?.id,
222 index: res.itemMetadata?.originalIndex ?? i,
223 success: res.itemMetadata?.success ?? false ,
224 error: res.itemMetadata?.error,
225 }));
226 }
227
228 // Fetch a service (for its current revision before an image patch, or to confirm an image landed).
229 async function getService ( ctx , serviceId ) {
230 return req (ctx, `/bookings/v2/services/${ serviceId }` , { method: "GET" });
231 }
232
233 /**
234 * Import an external image URL into Wix Media → { id, url }. Bookings binds a service image by the
235 * Wix Media file **id** (`mainMedia.image.id`), NOT a url — so an external url (e.g. a base44
236 * generate_image result) MUST be imported first; the raw url as the id stores (200) but renders
237 * nothing. `id` is the wixstatic file id (`<hash>~mv2.png`), `url` the permanent wixstatic url.
238 */
239 async function importImage ( ctx , url , displayName = "image.png" ) {
240 const r = await req (ctx, "/site-media/v1/files/import" , { body: { url, mimeType: "image/png" , displayName } });
241 const f = r.file || r;
242 if ( ! f?.id) throw new Error ( `import-file returned no file id: ${ JSON . stringify ( r ). slice ( 0 , 200 ) }` );
243 return { id: f.id, url: f.url };
244 }
245
246 /**
247 * Attach an image (optional). Writes under media.mainMedia + media.coverMedia; revision-checked.
248 * @param it { serviceId, revision, image: { id, url, width, height } } — `image.id` MUST be a Wix
249 * Media file id (from importImage), never a raw external url.
250 * ⚠️ Writing under media.image (not mainMedia/coverMedia) returns 200 but SILENTLY drops the image — a 200
251 * is not proof; confirm with getService and check media.mainMedia is populated. Never block on failure.
252 */
253 async function attachServiceImage ( ctx , it ) {
254 return req (ctx, `/bookings/v2/services/${ it . serviceId }` , {
255 method: "PATCH" ,
256 body: {
257 service: {
258 id: it.serviceId,
259 revision: it.revision,
260 media: { mainMedia: { image: it.image }, coverMedia: { image: it.image } },
261 },
262 },
263 });
264 }
265
266 /**
267 * ONE-CALL seed: install → resolve staff → categories → services → CLASS sessions → images, in the
268 * correct order, keeping ids in memory (no hand-threading of scheduleId/resourceId across exec
269 * calls). DEFAULT path — call it once instead of the individual functions.
270 *
271 * @param plan {{
272 * services: [{ type:"APPOINTMENT"|"CLASS", name, description, tagLine?, price?, free?, duration?,
273 * capacity?, category?(name), staffMemberIds?,
274 * sessions?: [{ start, end, capacity? }], // CLASS only; local "YYYY-MM-DDThh:mm:ss"
275 * imageUrl? }], // a plain image url; imported to Wix Media here, optional
276 * staffResourceId?: string, // defaults to the fresh install's owner (queryStaff()[0].resourceId)
277 * }}
278 * @returns { services:[...createServices], categories:[{id,name}], resourceId, sessionsScheduled, imagesAttached }
279 */
280 async function setupBookings ( ctx , { services = [], staffResourceId } = {}) {
281 await installBookingsApp (ctx);
282
283 let resourceId = staffResourceId;
284 if ( ! resourceId) {
285 const staff = await queryStaffWithRetry (ctx); // fresh install provisions the default owner async — poll
286 resourceId = staff[ 0 ]?.resourceId;
287 }
288
289 const catNames = [ ...new Set (services. map (( s ) => s.category). filter (Boolean))];
290 const cats = catNames. length ? await createCategories (ctx, catNames) : [];
291 const catIdByName = new Map (cats. map (( c ) => [c.name, c.id]));
292
293 const created = await createServices (ctx, services. map (( s ) => ({
294 type: s.type, name: s.name, description: s.description, tagLine: s.tagLine,
295 price: s.price, free: s.free, duration: s.duration, capacity: s.capacity,
296 categoryId: s.category ? catIdByName. get (s.category) : undefined ,
297 staffMemberIds: s.staffMemberIds ?? (s.type === "APPOINTMENT" && resourceId ? [resourceId] : undefined ),
298 })));
299
300 const sessions = [];
301 created. forEach (( c , i ) => {
302 const plan = services[i];
303 if (c.type === "CLASS" && Array. isArray (plan?.sessions)) {
304 for ( const ses of plan.sessions) {
305 sessions. push ({ scheduleId: c.scheduleId, resourceId, start: ses.start, end: ses.end, capacity: ses.capacity ?? plan.capacity });
306 }
307 }
308 });
309 const scheduled = sessions. length ? await scheduleClassSessions (ctx, sessions) : [];
310
311 let imagesAttached = 0 ;
312 for ( let i = 0 ; i < created. length ; i ++ ) {
313 const url = services[i]?.imageUrl; // a plain image url (e.g. base44 generate_image) — imported here
314 if ( ! url || ! created[i]?.id) continue ;
315 try {
316 const file = await importImage (ctx, url, `${ created [ i ]. slug || "service"}.png` ); // → Wix Media file id
317 await attachServiceImage (ctx, {
318 serviceId: created[i].id, revision: created[i].revision,
319 image: { id: file.id, url: file.url, width: 1024 , height: 1024 },
320 });
321 imagesAttached ++ ;
322 } catch { /* never block on image failure — leave the service text-only */ }
323 }
324
325 return { services: created, categories: cats, resourceId, sessionsScheduled: scheduled. length , imagesAttached };
326 }
327
328 module . exports = {
329 setupBookings,
330 installBookingsApp, listServices,
331 queryStaff, queryStaffWithRetry, createStaff, queryCategories, createCategories,
332 createServices, scheduleClassSessions, getService, importImage, attachServiceImage,
333 };