Setting the file. One moment.
Seed Bookings · Wix Headless Fast · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 7.26
Position 26 of 148
Type JavaScript
Size 12 KB
Lines 302 references/bookings/seed/ seed-bookings.mjs
JavaScript · 302 lines · 12 KB
14
// "category"? (name), "imageUrl"? | "imagePrompt"?,
15 // "sessions"?: [{ "start", "end", "capacity"? }] }] } // CLASS only; local "YYYY-MM-DDThh:mm:ss"
16 //
17 // Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
18 // read the live API reference; authoritative source recipe:
19 // wix-headless/references/inline-recipes/setup-bookings.md.
20 import { execFileSync } from "node:child_process" ;
21 import { readFileSync } from "node:fs" ;
22 import { resolveItemImages } from "../../shared/seed/images.mjs" ;
23
24 const API = "https://www.wixapis.com" ;
25 const BOOKINGS_APP_ID = "13d21c63-b5ec-5912-8397-c3a5ddb27a97" ;
26
27 export function makeCtx ({ cwd = process. cwd () } = {}) {
28 const config = JSON . parse ( readFileSync ( `${ cwd }/wix.config.json` , "utf8" ));
29 const siteId = config.siteId ?? config.projectId;
30 if ( ! siteId) throw new Error ( "wix.config.json has no siteId — is this a Wix CLI project?" );
31 const token = execFileSync ( "npx" , [ "@wix/cli@latest" , "token" , "--site" , siteId], {
32 encoding: "utf8" ,
33 cwd,
34 }). trim ();
35 if ( ! token) throw new Error ( "The Wix CLI returned no token — run `npx @wix/cli@latest login` first." );
36 return { token, siteId };
37 }
38
39 async function req ( ctx , path , { method = "POST" , body } = {}) {
40 const res = await fetch ( API + path, {
41 method,
42 headers: {
43 Authorization: `Bearer ${ ctx . token }` ,
44 "wix-site-id" : ctx.siteId,
45 "Content-Type" : "application/json" ,
46 },
47 body: body ? JSON . stringify (body) : undefined ,
48 });
49 const json = await res. json (). catch (() => ({}));
50 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
51 return json;
52 }
53
54 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
55
56 function slugify ( name ) {
57 return String (name || "" ). toLowerCase (). replace ( / [ ^ a-z0-9] + / g , "-" ). replace ( / ^ - +| - +$ / g , "" );
58 }
59
60 // plain service -> flat Services V2 create object. price omitted / free:true -> NO_FEE.
61 function buildService ( s ) {
62 const isAppointment = s.type === "APPOINTMENT" ;
63 const free = s.free === true || s.price == null ;
64 const out = {
65 type: s.type,
66 name: s.name,
67 description: s.description,
68 tagLine: s.tagLine,
69 defaultCapacity: s.capacity ?? (isAppointment ? 1 : undefined ),
70 onlineBooking: { enabled: true , requireManualApproval: false , allowMultipleRequests: false },
71 payment: free
72 ? { rateType: "NO_FEE" , options: { online: false , inPerson: true } }
73 : {
74 rateType: "FIXED" ,
75 fixed: { price: { value: String (s.price), currency: s.currency ?? "USD" } },
76 options: { online: true , inPerson: false },
77 },
78 category: { id: s.categoryId }, // mandatory for live-site visibility
79 locations: [{ type: "BUSINESS" }], // never OWNER_BUSINESS on the services endpoint
80 };
81 if (isAppointment) {
82 out.schedule = { availabilityConstraints: { sessionDurations: [s.duration ?? 60 ] } };
83 out.staffMemberIds = s.staffMemberIds; // resourceId(s) — non-empty or MISSING_APPOINTMENT_RESOURCES
84 }
85 return out;
86 }
87
88 // ---- operations ----------------------------------------------------------------------------------
89
90 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
91 export async function installBookingsApp ( ctx ) {
92 try {
93 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
94 tenant: { tenantType: "SITE" , id: ctx.siteId },
95 appInstance: { appDefId: BOOKINGS_APP_ID , enabled: true },
96 } });
97 } catch {
98 /* already installed is fine */
99 }
100 }
101
102 // ⚠️ staffMemberIds takes resourceId, NOT the staff id.
103 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/staff-members/staff-members/query-staff-members.md
104 export async function queryStaff ( ctx ) {
105 const r = await req (ctx, "/bookings/v1/staff-members/query" , {
106 body: { query: {}, fields: [ "RESOURCE_DETAILS" ] },
107 });
108 return (r.staffMembers ?? []). map (( m ) => ({ resourceId: m.resourceId, id: m.id, name: m.name }));
109 }
110
111 // A fresh install provisions the default "Business Owner" resource ASYNC — poll until it lands.
112 export async function queryStaffWithRetry ( ctx , { tries = 15 , delayMs = 2000 } = {}) {
113 let lastErr;
114 for ( let i = 0 ; i < tries; i ++ ) {
115 try {
116 const s = await queryStaff (ctx);
117 if (s. length ) return s;
118 } catch (e) {
119 lastErr = e;
120 }
121 if (i < tries - 1 ) await sleep (delayMs);
122 }
123 if (lastErr) throw lastErr;
124 return [];
125 }
126
127 // Every service needs a category.id or it's invisible on the live site. Idempotent by name.
128 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/categories-v2/query-categories.md
129 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/categories-v2/create-category.md
130 export async function createCategories ( ctx , names ) {
131 const existing = await req (ctx, "/bookings/v2/categories/query" , { body: { query: {} } });
132 const byName = new Map ((existing.categories ?? []). map (( c ) => [c.name, { id: c.id, name: c.name }]));
133 const out = [];
134 for ( const name of names) {
135 let cat = byName. get (name);
136 if ( ! cat) {
137 const r = await req (ctx, "/bookings/v2/categories" , { body: { category: { name } } });
138 cat = { id: r.category?.id, name };
139 byName. set (name, cat);
140 }
141 out. push (cat);
142 }
143 return out;
144 }
145
146 // Bulk-create services (APPOINTMENT + CLASS mixed). Run AFTER staff + categories.
147 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/bulk-create-services.md
148 export async function createServices ( ctx , services ) {
149 const body = { services: services. map (buildService), returnEntity: true };
150 const r = await req (ctx, "/bookings/v2/bulk/services/create" , { body });
151 return (r.results ?? []). map (( res , i ) => {
152 const item = res.item ?? {};
153 return {
154 id: item.id ?? res.itemMetadata?.id,
155 slug: item.mainSlug?.name ?? slugify (item.name ?? services[i]?.name),
156 revision: item.revision,
157 type: item.type ?? services[i]?.type,
158 scheduleId: item.schedule?.id,
159 success: res.itemMetadata?.success ?? false ,
160 error: res.itemMetadata?.error,
161 };
162 });
163 }
164
165 // CLASS only: schedule sessions (bulk Calendar Events V3). start/end are LOCAL wall-clock
166 // "YYYY-MM-DDThh:mm:ss" (no Z), today-or-future.
167 // docs: https://dev.wix.com/docs/api-reference/business-management/calendar/events-v3/bulk-create-event.md
168 export async function scheduleClassSessions ( ctx , sessions ) {
169 const body = {
170 events: sessions. map (( s ) => ({
171 event: {
172 scheduleId: s.scheduleId,
173 type: "CLASS" ,
174 start: { localDate: s.start },
175 end: { localDate: s.end },
176 resources: [{ id: s.resourceId, permissionRole: "WRITER" }], // non-empty + WRITER, else UNKNOWN_ROLE
177 ... (s.capacity != null ? { totalCapacity: s.capacity } : {}),
178 },
179 })),
180 };
181 const r = await req (ctx, "/calendar/v3/bulk/events/create" , { body });
182 return (r.results ?? []). map (( res ) => ({
183 id: res.itemMetadata?.id,
184 success: res.itemMetadata?.success ?? false ,
185 error: res.itemMetadata?.error,
186 }));
187 }
188
189 // Bookings binds a service image by Wix Media file ID — an external url must be imported
190 // first; a plan `imagePrompt` is generated (Wix AI, 1 credit) then imported. Both live in the
191 // shared util (parallel, resilient, never blocks the seed).
192 export { importImage } from "../../shared/seed/images.mjs" ;
193
194 // Writes under media.mainMedia + media.coverMedia (writing media.image 200s but silently drops).
195 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/update-service.md
196 export async function attachServiceImage ( ctx , it ) {
197 return req (ctx, `/bookings/v2/services/${ it . serviceId }` , {
198 method: "PATCH" ,
199 body: {
200 service: {
201 id: it.serviceId,
202 revision: it.revision,
203 media: { mainMedia: { image: it.image }, coverMedia: { image: it.image } },
204 },
205 },
206 });
207 }
208
209 /**
210 * ONE-CALL seed: install → resolve staff (poll) → categories → services → CLASS sessions →
211 * images, ids threaded in memory. The default path.
212 */
213 export async function setupBookings ( ctx , { services = [], staffResourceId } = {}) {
214 await installBookingsApp (ctx);
215
216 let resourceId = staffResourceId;
217 if ( ! resourceId) {
218 const staff = await queryStaffWithRetry (ctx);
219 resourceId = staff[ 0 ]?.resourceId;
220 }
221 if ( ! resourceId) throw new Error ( "No staff resource resolved — Bookings provisioning may still be in progress; re-run the seed." );
222
223 const catNames = [ ...new Set (services. map (( s ) => s.category). filter (Boolean))];
224 const cats = catNames. length ? await createCategories (ctx, catNames) : await createCategories (ctx, [ "Services" ]);
225 const catIdByName = new Map (cats. map (( c ) => [c.name, c.id]));
226 const defaultCatId = cats[ 0 ]?.id;
227
228 const created = await createServices (ctx, services. map (( s ) => ({
229 type: s.type,
230 name: s.name,
231 description: s.description,
232 tagLine: s.tagLine,
233 price: s.price,
234 currency: s.currency,
235 free: s.free,
236 duration: s.duration,
237 capacity: s.capacity,
238 categoryId: (s.category ? catIdByName. get (s.category) : undefined ) ?? defaultCatId,
239 staffMemberIds: s.staffMemberIds ?? (s.type === "APPOINTMENT" ? [resourceId] : undefined ),
240 })));
241
242 const sessions = [];
243 created. forEach (( c , i ) => {
244 const plan = services[i];
245 if (c.type === "CLASS" && c.scheduleId && Array. isArray (plan?.sessions)) {
246 for ( const ses of plan.sessions) {
247 sessions. push ({ scheduleId: c.scheduleId, resourceId, start: ses.start, end: ses.end, capacity: ses.capacity ?? plan.capacity });
248 }
249 }
250 });
251 const scheduled = sessions. length ? await scheduleClassSessions (ctx, sessions) : [];
252
253 // Pass 2 — images: resolve (import by url / generate by prompt) in one parallel wave, then
254 // attach. Failures leave the service text-only; the seed's exit never depends on images.
255 const files = await resolveItemImages (ctx, created. map (( c , i ) => ({
256 url: services[i]?.imageUrl,
257 path: services[i]?.imagePath,
258 prompt: services[i]?.imagePrompt,
259 displayName: `${ c ?. slug || "service"}.png` ,
260 })));
261 let imagesAttached = 0 ;
262 for ( let i = 0 ; i < created. length ; i ++ ) {
263 if ( ! files[i] || ! created[i]?.id) continue ;
264 try {
265 await attachServiceImage (ctx, {
266 serviceId: created[i].id,
267 revision: created[i].revision,
268 image: { id: files[i].id, url: files[i].url, width: 1024 , height: 1024 },
269 });
270 imagesAttached ++ ;
271 } catch {
272 /* never block on image failure — the service stays text-only */
273 }
274 }
275
276 return {
277 services: created,
278 categories: cats,
279 resourceId,
280 sessionsScheduled: scheduled. filter (( s ) => s.success). length ,
281 imagesAttached,
282 };
283 }
284
285 // ---- CLI entry ----------------------------------------------------------------------------------
286
287 const invokedDirectly = process.argv[ 1 ] && import . meta .url. endsWith (process.argv[ 1 ]. split ( "/" ). pop ());
288 if (invokedDirectly) {
289 const planPath = process.argv[ 2 ];
290 if ( ! planPath) {
291 console. error ( "usage: node seed-bookings.mjs <plan.json> (run from the project root)" );
292 process. exit ( 1 );
293 }
294 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
295 const ctx = makeCtx ();
296 setupBookings (ctx, plan)
297 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
298 . catch (( e ) => {
299 console. error (e.message);
300 process. exit ( 1 );
301 });
302 }