Setting the file. One moment.
Seed Bookings · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page 184
export async function createBookingPolicy
— line 184
This file
Number 16.160
Position 160 of 828
Type JavaScript
Size 20 KB
Lines 409 bookings/seed/ seed-bookings.mjs
JavaScript · 409 lines · 20 KB
14 // Plan shape (see SEED.md):
15 // { "currency"?, "staff"?: ["Name"], "locations"?: [{ "name", "timeZone", "address"? }],
16 // "services": [{ "type": "APPOINTMENT"|"CLASS"|"COURSE", "name", "description", "tagLine"?,
17 // "price"? (number | { "from": number }), "priceText"?, "free"?, "deposit"?, "payInFull"?,
18 // "duration"? (APPOINTMENT, minutes), "capacity"?, "waitlist"?, "maxParticipants"?,
19 // "requireManualApproval"?, "conferencing"?, "staff"? (name | [names]), "location"? (name),
20 // "category"? (name), "imageMediaId"? | "imageUrl"? | "imagePath"? | "imagePrompt"?,
21 // "sessions"?: [{ "start", "end", "capacity"? }], // CLASS: one-off sessions, local "YYYY-MM-DDThh:mm:ss"
22 // "weekly"?: { "days": ["MONDAY"], "time": "18:00", "duration": 60, "start", "end" } }] } // CLASS or COURSE: recurring
23 //
24 // Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
25 // read the live API reference; every call below carries a docs: line with its reference page.
26 import { setSiteCurrency } from "../../shared/seed/site.mjs" ;
27 import { readFileSync } from "node:fs" ;
28 import { resolveItemImages } from "../../shared/seed/images.mjs" ;
29 import { seedSiteId } from "../../shared/seed/site-context.mjs" ;
30 import { wixToken } from "../../shared/seed/wix-cli.mjs" ;
31 import { isMain } from "../../shared/seed/main.mjs" ;
32
33 const API = "https://www.wixapis.com" ;
34 const BOOKINGS_APP_ID = "13d21c63-b5ec-5912-8397-c3a5ddb27a97" ;
35 const WEEKDAYS = [ "SUNDAY" , "MONDAY" , "TUESDAY" , "WEDNESDAY" , "THURSDAY" , "FRIDAY" , "SATURDAY" ];
36
37 export function makeCtx ({ cwd = process. cwd () } = {}) {
38 // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
39 // a seed there unless --allow-parent is passed after the user confirmed).
40 const siteId = seedSiteId ({ cwd, argv: process.argv });
41 const token = wixToken (siteId, cwd);
42 return { token, siteId };
43 }
44
45 async function req ( ctx , path , { method = "POST" , body } = {}) {
46 const res = await fetch ( API + path, {
47 method,
48 headers: {
49 Authorization: `Bearer ${ ctx . token }` ,
50 "wix-site-id" : ctx.siteId,
51 "Content-Type" : "application/json" ,
52 },
53 body: body ? JSON . stringify (body) : undefined ,
54 });
55 const json = await res. json (). catch (() => ({}));
56 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
57 return json;
58 }
59
60 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
61
62 function slugify ( name ) {
63 return String (name || "" ). toLowerCase (). replace ( / [ ^ a-z0-9] + / g , "-" ). replace ( / ^ - +| - +$ / g , "" );
64 }
65
66 const money = ( n , currency ) => ({ value: String (n), currency: currency ?? "USD" });
67
68 // plain service -> flat Services V2 create object.
69 // price: number -> FIXED; { from } -> VARIED (defaultPrice); priceText -> CUSTOM; omitted / free -> NO_FEE.
70 // deposit -> payment.options.deposit + the rate's deposit amount; payInFull -> fullUpfrontPaymentAllowed.
71 // FIXED/VARIED pay online; CUSTOM/NO_FEE pay in person (the API needs one option true even for NO_FEE).
72 function buildService ( s ) {
73 const isAppointment = s.type === "APPOINTMENT" ;
74 const varied = s.price != null && typeof s.price === "object" ;
75 const custom = ! varied && typeof s.priceText === "string" && s.priceText. trim ();
76 const free = ! varied && ! custom && (s.free === true || s.price == null );
77 const deposit = s.deposit != null && Number (s.deposit) > 0 ? { deposit: money (s.deposit, s.currency), fullUpfrontPaymentAllowed: s.payInFull === true } : {};
78 let payment;
79 if (free) payment = { rateType: "NO_FEE" , options: { online: false , inPerson: true } };
80 else if (custom) payment = { rateType: "CUSTOM" , custom: { description: s.priceText. trim () }, options: { online: false , inPerson: true } };
81 else if (varied) payment = { rateType: "VARIED" , varied: { defaultPrice: money (s.price.from, s.currency), ... deposit }, options: { online: true , inPerson: false , deposit: !! deposit.deposit } };
82 else payment = { rateType: "FIXED" , fixed: { price: money (s.price, s.currency), ... deposit }, options: { online: true , inPerson: false , deposit: !! deposit.deposit } };
83 const out = {
84 type: s.type,
85 name: s.name,
86 description: s.description,
87 tagLine: s.tagLine,
88 defaultCapacity: s.capacity ?? (isAppointment ? 1 : undefined ),
89 onlineBooking: { enabled: true , requireManualApproval: s.requireManualApproval === true , allowMultipleRequests: false },
90 payment,
91 category: { id: s.categoryId }, // mandatory for live-site visibility
92 // A named business location by id; otherwise the default business location. Never OWNER_BUSINESS on the services endpoint.
93 locations: [s.locationId ? { type: "BUSINESS" , business: { id: s.locationId } } : { type: "BUSINESS" }],
94 ... (s.conferencing === true ? { conferencing: { enabled: true } } : {}),
95 ... (s.bookingPolicyId ? { bookingPolicy: { id: s.bookingPolicyId } } : {}),
96 };
97 if (isAppointment) {
98 out.schedule = { availabilityConstraints: { sessionDurations: [s.duration ?? 60 ] } }; // classes and courses: no sessionDurations
99 out.staffMemberIds = s.staffMemberIds; // resourceId(s) — non-empty or MISSING_APPOINTMENT_RESOURCES
100 }
101 return out;
102 }
103
104 // ---- operations ----------------------------------------------------------------------------------
105
106 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
107 export async function installBookingsApp ( ctx ) {
108 try {
109 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
110 tenant: { tenantType: "SITE" , id: ctx.siteId },
111 appInstance: { appDefId: BOOKINGS_APP_ID , enabled: true },
112 } });
113 } catch {
114 /* already installed is fine */
115 }
116 }
117
118 // staffMemberIds takes resourceId, NOT the staff id.
119 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/staff-members/staff-members/query-staff-members.md
120 export async function queryStaff ( ctx ) {
121 const r = await req (ctx, "/bookings/v1/staff-members/query" , {
122 body: { query: {}, fields: [ "RESOURCE_DETAILS" ] },
123 });
124 return (r.staffMembers ?? []). map (( m ) => ({ resourceId: m.resourceId, id: m.id, name: m.name }));
125 }
126
127 // A fresh install provisions the default "Business Owner" resource ASYNC — poll until it lands.
128 export async function queryStaffWithRetry ( ctx , { tries = 15 , delayMs = 2000 } = {}) {
129 let lastErr;
130 for ( let i = 0 ; i < tries; i ++ ) {
131 try {
132 const s = await queryStaff (ctx);
133 if (s. length ) return s;
134 } catch (e) {
135 lastErr = e;
136 }
137 if (i < tries - 1 ) await sleep (delayMs);
138 }
139 if (lastErr) throw lastErr;
140 return [];
141 }
142
143 // Extra staff members by name — idempotent (an existing name is reused). The response carries the
144 // resourceId the services need; a missing one is polled from the query.
145 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/staff-members/staff-members/create-staff-member.md
146 export async function createStaff ( ctx , names , existing ) {
147 const byName = new Map (existing. map (( m ) => [m.name, m]));
148 for ( const name of names) {
149 if (byName. has (name)) continue ;
150 const r = await req (ctx, "/bookings/v1/staff-members" , { body: { staffMember: { name }, fields: [ "RESOURCE_DETAILS" ] } });
151 const m = r.staffMember ?? {};
152 byName. set (name, { id: m.id, name, resourceId: m.resourceId ?? m.resource?.id });
153 }
154 const missing = [ ... byName. values ()]. filter (( m ) => ! m.resourceId);
155 if (missing. length ) {
156 for ( let i = 0 ; i < 10 ; i ++ ) {
157 await sleep ( 2000 );
158 const fresh = await queryStaff (ctx);
159 for ( const m of missing) m.resourceId = fresh. find (( f ) => f.id === m.id || f.name === m.name)?.resourceId;
160 if (missing. every (( m ) => m.resourceId)) break ;
161 }
162 }
163 return [ ... byName. values ()];
164 }
165
166 // Extra business locations by name — idempotent. `timeZone` is required by the API; the address is
167 // what the plan supplies (formattedAddress, city, country).
168 // docs: https://dev.wix.com/docs/api-reference/business-management/locations/list-locations.md
169 // docs: https://dev.wix.com/docs/api-reference/business-management/locations/create-location.md
170 export async function createLocations ( ctx , locations ) {
171 const existing = await req (ctx, "/locations/v1/locations" , { method: "GET" });
172 const byName = new Map ((existing.locations ?? []). map (( l ) => [l.name, { id: l.id, name: l.name, default: l.default === true }]));
173 for ( const loc of locations) {
174 if ( ! loc?.name || byName. has (loc.name)) continue ;
175 if ( ! loc.timeZone) throw new Error ( `location "${ loc . name }" needs a timeZone (IANA, e.g. "Europe/Berlin")` );
176 const r = await req (ctx, "/locations/v1/locations" , { body: { location: { name: loc.name, timeZone: loc.timeZone, address: loc.address ?? { formattedAddress: loc.name } } } });
177 byName. set (loc.name, { id: r.location?.id, name: loc.name, default: false });
178 }
179 return [ ... byName. values ()];
180 }
181
182 // A booking policy per service that asks for a waitlist or a participants cap; the service references it by id.
183 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/policies/booking-policies/create-booking-policy.md
184 export async function createBookingPolicy ( ctx , s ) {
185 const r = await req (ctx, "/bookings/v1/booking-policies" , { body: { bookingPolicy: {
186 name: `${ s . name } policy` ,
187 ... (s.waitlist != null ? { waitlistPolicy: { enabled: true , capacity: Number (s.waitlist) } } : {}),
188 ... (s.maxParticipants != null ? { participantsPolicy: { maxParticipantsPerBooking: Number (s.maxParticipants) } } : {}),
189 } } });
190 return r.bookingPolicy?.id;
191 }
192
193 // Every service needs a category.id or it's invisible on the live site. Idempotent by name.
194 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/categories-v2/query-categories.md
195 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/categories-v2/create-category.md
196 export async function createCategories ( ctx , names ) {
197 const existing = await req (ctx, "/bookings/v2/categories/query" , { body: { query: {} } });
198 const byName = new Map ((existing.categories ?? []). map (( c ) => [c.name, { id: c.id, name: c.name }]));
199 const out = [];
200 for ( const name of names) {
201 let cat = byName. get (name);
202 if ( ! cat) {
203 const r = await req (ctx, "/bookings/v2/categories" , { body: { category: { name } } });
204 cat = { id: r.category?.id, name };
205 byName. set (name, cat);
206 }
207 out. push (cat);
208 }
209 return out;
210 }
211
212 // Bulk-create services (APPOINTMENT + CLASS + COURSE mixed). Run AFTER staff, locations, policies + categories.
213 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/bulk-create-services.md
214 export async function createServices ( ctx , services ) {
215 const body = { services: services. map (buildService), returnEntity: true };
216 const r = await req (ctx, "/bookings/v2/bulk/services/create" , { body });
217 return (r.results ?? []). map (( res , i ) => {
218 const item = res.item ?? {};
219 return {
220 id: item.id ?? res.itemMetadata?.id,
221 slug: item.mainSlug?.name ?? slugify (item.name ?? services[i]?.name),
222 revision: item.revision,
223 type: item.type ?? services[i]?.type,
224 scheduleId: item.schedule?.id,
225 success: res.itemMetadata?.success ?? false ,
226 error: res.itemMetadata?.error,
227 };
228 });
229 }
230
231 // Calendar Events V3, bulk: one-off CLASS sessions and the weekly recurring (MASTER) sessions of
232 // classes and courses. start/end are LOCAL wall-clock "YYYY-MM-DDThh:mm:ss" (no Z), today-or-future.
233 // A recurring event repeats WEEKLY on ONE weekday (the API takes a single day per event) until `until`.
234 // docs: https://dev.wix.com/docs/api-reference/business-management/calendar/events-v3/bulk-create-event.md
235 // docs: https://dev.wix.com/docs/api-reference/business-management/calendar/events-v3/create-event.md
236 export async function scheduleEvents ( ctx , events ) {
237 const body = {
238 events: events. map (( s ) => ({
239 event: {
240 scheduleId: s.scheduleId,
241 type: s.type ?? "CLASS" ,
242 start: { localDate: s.start },
243 end: { localDate: s.end },
244 resources: [{ id: s.resourceId, permissionRole: "WRITER" }], // non-empty + WRITER, else UNKNOWN_ROLE
245 ... (s.capacity != null ? { totalCapacity: s.capacity } : {}),
246 ... (s.day ? { recurrenceRule: { frequency: "WEEKLY" , interval: 1 , days: [s.day], until: { localDate: s.until } } } : {}),
247 },
248 })),
249 };
250 const r = await req (ctx, "/calendar/v3/bulk/events/create" , { body });
251 return (r.results ?? []). map (( res ) => ({
252 id: res.itemMetadata?.id,
253 success: res.itemMetadata?.success ?? false ,
254 error: res.itemMetadata?.error,
255 }));
256 }
257 export const scheduleClassSessions = scheduleEvents;
258
259 const pad2 = ( n ) => String (n). padStart ( 2 , "0" );
260 const localDate = ( d ) => `${ d . getFullYear () }-${ pad2 ( d . getMonth () + 1 ) }-${ pad2 ( d . getDate ()) }` ;
261
262 // `weekly: { days, time, duration, start, end }` → one MASTER event per weekday, starting on the first
263 // such weekday on/after `start`, repeating until the end of `end`.
264 export function weeklyEvents ( plan , { scheduleId , resourceId , type }) {
265 const w = plan.weekly;
266 if ( ! w || ! Array. isArray (w.days) || ! w.days. length || ! w.start || ! w.end) return [];
267 const [ hh , mm ] = String (w.time ?? "09:00" ). split ( ":" ). map (Number);
268 const minutes = Number (w.duration ?? 60 );
269 return w.days. map (( day ) => {
270 const wanted = WEEKDAYS . indexOf ( String (day). toUpperCase ());
271 if (wanted < 0 ) throw new Error ( `weekly.days: unknown weekday "${ day }"` );
272 const first = new Date ( `${ w . start }T00:00:00` );
273 while (first. getDay () !== wanted) first. setDate (first. getDate () + 1 );
274 const startAt = new Date (first. getFullYear (), first. getMonth (), first. getDate (), hh || 0 , mm || 0 );
275 const endAt = new Date (startAt. getTime () + minutes * 60_000 );
276 const fmt = ( d ) => `${ localDate ( d ) }T${ pad2 ( d . getHours ()) }:${ pad2 ( d . getMinutes ()) }:00` ;
277 return { scheduleId, resourceId, type, start: fmt (startAt), end: fmt (endAt), day: String (day). toUpperCase (), until: `${ w . end }T23:59:59` , capacity: plan.capacity };
278 });
279 }
280
281 // Bookings binds a service image by Wix Media file ID — an external url must be imported
282 // first; a plan `imagePrompt` is generated (Wix AI, 1 credit) then imported. Both live in the
283 // shared util (parallel, resilient, never blocks the seed).
284 export { importImage } from "../../shared/seed/images.mjs" ;
285
286 // Writes under media.mainMedia + media.coverMedia (writing media.image 200s but silently drops).
287 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/update-service.md
288 export async function attachServiceImage ( ctx , it ) {
289 return req (ctx, `/bookings/v2/services/${ it . serviceId }` , {
290 method: "PATCH" ,
291 body: {
292 service: {
293 id: it.serviceId,
294 revision: it.revision,
295 media: { mainMedia: { image: it.image }, coverMedia: { image: it.image } },
296 },
297 },
298 });
299 }
300
301 /**
302 * ONE-CALL seed: install → resolve staff (poll) + extra staff → extra locations → policies →
303 * categories → services → CLASS sessions + weekly recurring sessions → images, ids threaded in
304 * memory. The default path.
305 */
306 export async function setupBookings ( ctx , { services = [], staff = [], locations = [], staffResourceId , currency } = {}) {
307 // Before any service exists: a service's price is stored in the site currency.
308 if (currency) await setSiteCurrency (ctx, currency);
309 await installBookingsApp (ctx);
310
311 let allStaff = await queryStaffWithRetry (ctx);
312 const wantedStaff = [ ...new Set ([ ... staff, ... services. flatMap (( s ) => (Array. isArray (s.staff) ? s.staff : s.staff ? [s.staff] : []))]. filter (Boolean))];
313 if (wantedStaff. length ) allStaff = await createStaff (ctx, wantedStaff, allStaff);
314 const resourceId = staffResourceId ?? allStaff[ 0 ]?.resourceId;
315 if ( ! resourceId) throw new Error ( "No staff resource resolved — Bookings provisioning may still be in progress; re-run the seed." );
316 const resourceByName = new Map (allStaff. map (( m ) => [m.name, m.resourceId]));
317 const staffIdsFor = ( s ) => {
318 const names = Array. isArray (s.staff) ? s.staff : s.staff ? [s.staff] : [];
319 const ids = names. map (( n ) => resourceByName. get (n)). filter (Boolean);
320 return ids. length ? ids : [resourceId];
321 };
322
323 const allLocations = locations. length ? await createLocations (ctx, locations) : [];
324 const locationByName = new Map (allLocations. map (( l ) => [l.name, l.id]));
325 for ( const s of services) if (s.location && ! locationByName. has (s.location)) throw new Error ( `service "${ s . name }" names location "${ s . location }" — add it to the plan's "locations"` );
326
327 const policyIds = new Map ();
328 for ( const s of services) if (s.waitlist != null || s.maxParticipants != null ) policyIds. set (s.name, await createBookingPolicy (ctx, s));
329
330 const catNames = [ ...new Set (services. map (( s ) => s.category). filter (Boolean))];
331 const cats = catNames. length ? await createCategories (ctx, catNames) : await createCategories (ctx, [ "Services" ]);
332 const catIdByName = new Map (cats. map (( c ) => [c.name, c.id]));
333 const defaultCatId = cats[ 0 ]?.id;
334
335 const created = await createServices (ctx, services. map (( s ) => ({
336 ... s,
337 currency: s.currency,
338 categoryId: (s.category ? catIdByName. get (s.category) : undefined ) ?? defaultCatId,
339 locationId: s.location ? locationByName. get (s.location) : undefined ,
340 bookingPolicyId: policyIds. get (s.name),
341 staffMemberIds: s.staffMemberIds ?? (s.type === "APPOINTMENT" ? staffIdsFor (s) : undefined ),
342 })));
343
344 const events = [];
345 created. forEach (( c , i ) => {
346 const plan = services[i];
347 if ( ! c.scheduleId || ! plan) return ;
348 const staffResource = staffIdsFor (plan)[ 0 ];
349 if (c.type === "CLASS" && Array. isArray (plan.sessions)) {
350 for ( const ses of plan.sessions) events. push ({ scheduleId: c.scheduleId, resourceId: staffResource, type: "CLASS" , start: ses.start, end: ses.end, capacity: ses.capacity ?? plan.capacity });
351 }
352 if (c.type === "CLASS" || c.type === "COURSE" ) events. push ( ... weeklyEvents (plan, { scheduleId: c.scheduleId, resourceId: staffResource, type: c.type }));
353 });
354 const scheduled = events. length ? await scheduleEvents (ctx, events) : [];
355
356 // Pass 2 — images: resolve (import by url / generate by prompt) in one parallel wave, then
357 // attach. Failures leave the service text-only; the seed's exit never depends on images.
358 const files = await resolveItemImages (ctx, created. map (( c , i ) => ({
359 mediaId: services[i]?.imageMediaId,
360 url: services[i]?.imageUrl,
361 path: services[i]?.imagePath,
362 prompt: services[i]?.imagePrompt,
363 displayName: `${ c ?. slug || "service"}.png` ,
364 })));
365 let imagesAttached = 0 ;
366 for ( let i = 0 ; i < created. length ; i ++ ) {
367 if ( ! files[i] || ! created[i]?.id) continue ;
368 try {
369 await attachServiceImage (ctx, {
370 serviceId: created[i].id,
371 revision: created[i].revision,
372 image: { id: files[i].id, url: files[i].url, width: 1024 , height: 1024 },
373 });
374 imagesAttached ++ ;
375 } catch {
376 /* never block on image failure — the service stays text-only */
377 }
378 }
379
380 return {
381 services: created,
382 categories: cats,
383 staff: allStaff,
384 locations: allLocations,
385 resourceId,
386 sessionsScheduled: scheduled. filter (( s ) => s.success). length ,
387 sessionErrors: scheduled. filter (( s ) => ! s.success). map (( s ) => s.error). filter (Boolean),
388 imagesAttached,
389 };
390 }
391
392 // ---- CLI entry ----------------------------------------------------------------------------------
393
394 const invokedDirectly = isMain ( import . meta .url);
395 if (invokedDirectly) {
396 const planPath = process.argv[ 2 ];
397 if ( ! planPath) {
398 console. error ( "usage: node seed-bookings.mjs <plan.json> (run from the project root)" );
399 process. exit ( 1 );
400 }
401 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
402 const ctx = makeCtx ();
403 setupBookings (ctx, plan)
404 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
405 . catch (( e ) => {
406 console. error (e.message);
407 process. exit ( 1 );
408 });
409 }