@vuoma/sdk 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +45 -0
- package/README.md +179 -47
- package/package.json +25 -6
- package/src/index.d.ts +64 -3
- package/src/index.js +87 -13
- package/src/member-contracts.js +98 -0
- package/src/organization-contracts.generated.d.ts +31 -0
- package/src/organization-contracts.generated.js +1343 -0
- package/src/organization.d.ts +23 -0
- package/src/organization.js +95 -0
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { bookingInput, visitInput, bookingId, feeAmount, validBookingResult, validQuote, validVisitResult, availabilityRange, validAvailability, validBookingCatalog, accountInput, displayName, emailCode, validCapability, accountResolution } from "./member-contracts.js"
|
|
2
|
+
|
|
1
3
|
// Public package: no environment reads, provider clients, persistence or secrets.
|
|
2
4
|
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
|
|
3
|
-
const appKeyPattern = /^vuoma_pk_[0-9a-f]{32}$/u
|
|
4
5
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
|
|
5
6
|
const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u
|
|
6
7
|
const maximumRange = 63 * 24 * 60 * 60 * 1000
|
|
@@ -135,26 +136,45 @@ export function createVuomaClient(options) {
|
|
|
135
136
|
if (typeof organizationSlug !== "string" || organizationSlug.length < 2 || organizationSlug.length > 80 || !slugPattern.test(organizationSlug)) throw new TypeError("Use an exact organization slug.")
|
|
136
137
|
const organizationId = options.organizationId
|
|
137
138
|
if (organizationId !== undefined && !uuid(organizationId)) throw new TypeError("Use a valid organization ID.")
|
|
138
|
-
const appKey = options.appKey
|
|
139
|
-
if (appKey !== undefined && (typeof appKey !== "string" || !appKeyPattern.test(appKey))) throw new TypeError("Use a publishable Vuoma app key.")
|
|
140
139
|
const requestFetch = options.fetch ?? globalThis.fetch
|
|
141
140
|
if (typeof requestFetch !== "function") throw new TypeError("A fetch implementation is required.")
|
|
142
141
|
const tokenAdapter = options.tokenAdapter
|
|
143
142
|
if (tokenAdapter !== undefined && (!object(tokenAdapter) || typeof tokenAdapter.getSession !== "function")) throw new TypeError("Provide a member session adapter.")
|
|
143
|
+
const bookingTokenAdapter = options.bookingTokenAdapter
|
|
144
|
+
if (bookingTokenAdapter !== undefined && (!object(bookingTokenAdapter) || typeof bookingTokenAdapter.getCredential !== "function" || tokenAdapter)) throw new TypeError("Provide either a member session adapter or a booking credential adapter.")
|
|
144
145
|
const maximumBytes = options.maximumResponseBytes ?? 2 * 1024 * 1024
|
|
145
146
|
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1024 || maximumBytes > 16 * 1024 * 1024) throw new TypeError("Use a response limit between 1 KiB and 16 MiB.")
|
|
146
147
|
const memberRoot = `/api/v1/o/${organizationSlug}/member`
|
|
148
|
+
const selfRoot = `/api/v1/o/${organizationSlug}/me`
|
|
149
|
+
const authRoot = `/api/v1/o/${organizationSlug}/auth`
|
|
147
150
|
|
|
148
151
|
async function readSession() {
|
|
149
152
|
if (!tokenAdapter) throw invalidSession()
|
|
150
153
|
return verifySession(await tokenAdapter.getSession(), organizationSlug, organizationId)
|
|
151
154
|
}
|
|
152
155
|
|
|
156
|
+
async function readBookingSession() {
|
|
157
|
+
if (!bookingTokenAdapter) return readSession()
|
|
158
|
+
const credential = await bookingTokenAdapter.getCredential()
|
|
159
|
+
if (!object(credential) || credential.organization_slug !== organizationSlug) throw invalidSession()
|
|
160
|
+
const claims = memberClaims(credential.access_token)
|
|
161
|
+
if (organizationId && claims.app_metadata.organization_id !== organizationId) throw invalidSession()
|
|
162
|
+
return { session: credential, identity: `${organizationSlug}:${claims.sub}:${claims.session_id}:${claims.app_metadata.organization_id}` }
|
|
163
|
+
}
|
|
164
|
+
function checkSignal(signal) {
|
|
165
|
+
if (signal?.aborted) { const error = new Error("The request was cancelled."); error.name = "AbortError"; throw error }
|
|
166
|
+
}
|
|
167
|
+
function checked(value, valid) { if (!valid(value)) throw invalidResponse(); return value }
|
|
168
|
+
function setupResult(value) {
|
|
169
|
+
if (value.ok !== true || value.organization_slug != null && value.organization_slug !== organizationSlug) throw invalidResponse()
|
|
170
|
+
return value
|
|
171
|
+
}
|
|
172
|
+
|
|
153
173
|
async function request(path, { method = "GET", body, accessToken, signal, query, privateResponse = false, empty = false } = {}) {
|
|
174
|
+
checkSignal(signal)
|
|
154
175
|
const url = new URL(path, origin)
|
|
155
176
|
if (query) for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value)
|
|
156
177
|
const headers = { accept: "application/json" }
|
|
157
|
-
if (appKey !== undefined) headers["x-vuoma-app-key"] = appKey
|
|
158
178
|
if (body !== undefined) headers["content-type"] = "application/json"
|
|
159
179
|
if (accessToken) headers.authorization = `Bearer ${accessToken}`
|
|
160
180
|
const response = await requestFetch(url.href, {
|
|
@@ -162,11 +182,13 @@ export function createVuomaClient(options) {
|
|
|
162
182
|
cache: privateResponse ? "no-store" : "default",
|
|
163
183
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
164
184
|
})
|
|
185
|
+
checkSignal(signal)
|
|
165
186
|
// Custom fetch adapters must also honor the no-redirect destination boundary.
|
|
166
187
|
if (response.redirected || (response.url && response.url !== url.href)) throw invalidResponse()
|
|
167
188
|
const declared = Number(response.headers.get("content-length"))
|
|
168
189
|
if (Number.isFinite(declared) && declared > maximumBytes) throw invalidResponse()
|
|
169
190
|
const text = await response.text()
|
|
191
|
+
checkSignal(signal)
|
|
170
192
|
if (utf8Length(text) > maximumBytes) throw invalidResponse()
|
|
171
193
|
let value
|
|
172
194
|
try { value = text ? JSON.parse(text) : null } catch { if (response.ok) throw invalidResponse() }
|
|
@@ -185,12 +207,14 @@ export function createVuomaClient(options) {
|
|
|
185
207
|
return value
|
|
186
208
|
}
|
|
187
209
|
|
|
188
|
-
async function accountRequest(path, requestOptions = {}, parse = value => value) {
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
const
|
|
210
|
+
async function accountRequest(path, requestOptions = {}, parse = value => value, booking = false) {
|
|
211
|
+
const read = booking ? readBookingSession : readSession
|
|
212
|
+
const initial = await read()
|
|
213
|
+
const value = await request(`${selfRoot}${path}`, { ...requestOptions, accessToken: initial.session.access_token, privateResponse: true })
|
|
214
|
+
const current = await read()
|
|
215
|
+
checkSignal(requestOptions.signal)
|
|
192
216
|
if (current.identity !== initial.identity) throw new VuomaApiError(401, "member_session_changed", "Your signed-in account changed. Try again.")
|
|
193
|
-
return parse(value, initial.session.customer
|
|
217
|
+
return parse(value, initial.session.customer?.id)
|
|
194
218
|
}
|
|
195
219
|
|
|
196
220
|
function profile(value, customerId) {
|
|
@@ -225,24 +249,74 @@ export function createVuomaClient(options) {
|
|
|
225
249
|
if (value.schedule.sessions.length > 250) throw invalidResponse()
|
|
226
250
|
return value
|
|
227
251
|
},
|
|
252
|
+
async getBookingCatalog({ signal } = {}) {
|
|
253
|
+
return checked(await request(`${memberRoot}/booking-catalog`, { signal }), validBookingCatalog)
|
|
254
|
+
},
|
|
255
|
+
async getAppointmentAvailability({ from, to, signal }) {
|
|
256
|
+
const range = availabilityRange({ from, to })
|
|
257
|
+
return checked(await request(`${memberRoot}/availability`, { query: range, signal }), value => validAvailability(value, range))
|
|
258
|
+
},
|
|
228
259
|
async getCatalog({ includeCreditPurchases = false, signal } = {}) {
|
|
229
260
|
return catalog(await request(`${memberRoot}/catalog`, { signal, query: includeCreditPurchases ? { include_credit_purchases: "true" } : undefined }))
|
|
230
261
|
},
|
|
231
262
|
}),
|
|
232
263
|
auth: Object.freeze({
|
|
264
|
+
async requestAccountCode({ mode, email, signupIntent, displayName: name, signal }) {
|
|
265
|
+
accountInput({ mode, email, signupIntent })
|
|
266
|
+
const body = mode === "signup" ? { email, signup_intent: signupIntent, ...(["new", "auto"].includes(signupIntent) ? { display_name: displayName(name) } : {}) } : { email }
|
|
267
|
+
const value = setupResult(await request(`${authRoot}/${mode === "signup" ? "sign-up" : "recovery"}`, { method: "POST", body, signal, privateResponse: true }))
|
|
268
|
+
if (value.mode !== mode || value.requires_email_code !== true) throw invalidResponse()
|
|
269
|
+
return { ok: true, mode, requires_email_code: true }
|
|
270
|
+
},
|
|
271
|
+
async confirmAccountCode({ mode, email, signupIntent, code, signal }) {
|
|
272
|
+
accountInput({ mode, email, signupIntent })
|
|
273
|
+
const resolve = mode === "signup" && ["auto", "activate"].includes(signupIntent)
|
|
274
|
+
const path = mode === "signup" ? resolve ? "account" : "sign-up" : "recovery"
|
|
275
|
+
const value = setupResult(await request(`${authRoot}/${path}/confirm-code`, { method: "POST", body: { email, code: emailCode(code) }, signal, privateResponse: true }))
|
|
276
|
+
if (!validCapability(value.capability)) throw invalidResponse()
|
|
277
|
+
let resolution
|
|
278
|
+
try { if (resolve) resolution = accountResolution(value.account_resolution) } catch { throw invalidResponse() }
|
|
279
|
+
return { organization_slug: organizationSlug, value: value.capability, ...(resolution ? { account_resolution: resolution } : {}) }
|
|
280
|
+
},
|
|
281
|
+
async completePassword({ capability, password, signal }) {
|
|
282
|
+
if (!object(capability) || capability.organization_slug !== organizationSlug || !validCapability(capability.value)) throw invalidSession()
|
|
283
|
+
if (typeof password !== "string" || !password || password.length > 1024) throw new TypeError("Provide a password.")
|
|
284
|
+
setupResult(await request(`${authRoot}/password`, { method: "POST", body: { capability: capability.value, password, password_confirmation: password }, signal, privateResponse: true }))
|
|
285
|
+
},
|
|
233
286
|
async signIn({ email, password, signal }) {
|
|
234
287
|
if (typeof email !== "string" || !email.trim() || email.length > 320 || typeof password !== "string" || !password || password.length > 1024) throw new TypeError("Provide an email address and password.")
|
|
235
|
-
return sessionResponse(await request(`${
|
|
288
|
+
return sessionResponse(await request(`${authRoot}/sign-in`, { method: "POST", body: { email, password }, signal, privateResponse: true }), organizationSlug, organizationId)
|
|
236
289
|
},
|
|
237
290
|
async refresh({ refreshToken: token, signal }) {
|
|
238
|
-
return sessionResponse(await request(
|
|
291
|
+
return sessionResponse(await request(`${authRoot}/refresh`, { method: "POST", body: { refresh_token: refreshToken(token) }, signal, privateResponse: true }), organizationSlug, organizationId)
|
|
239
292
|
},
|
|
240
293
|
async signOut({ refreshToken: token, signal }) {
|
|
241
|
-
await request(
|
|
294
|
+
await request(`${authRoot}/sign-out`, { method: "POST", body: { refresh_token: refreshToken(token) }, signal, privateResponse: true, empty: true })
|
|
242
295
|
},
|
|
243
296
|
}),
|
|
244
297
|
member: Object.freeze({
|
|
245
|
-
|
|
298
|
+
bookClass(input, { signal } = {}) {
|
|
299
|
+
const body = bookingInput(input)
|
|
300
|
+
return accountRequest("/commerce/bookings", { method: "POST", body, signal }, value => checked(value, validBookingResult), true)
|
|
301
|
+
},
|
|
302
|
+
getCancellationQuote(id, { signal } = {}) {
|
|
303
|
+
bookingId(id)
|
|
304
|
+
return accountRequest(`/commerce/bookings/${id}/cancellation-quote`, { signal, query: { include_booking_policy_context: "true" } }, value => checked(value, result => result.api_version === 1 && result.booking_id === id && validQuote(result.quote)), true)
|
|
305
|
+
},
|
|
306
|
+
cancelBooking(id, { expectedFeeAmount, signal } = {}) {
|
|
307
|
+
bookingId(id)
|
|
308
|
+
const body = expectedFeeAmount === undefined ? {} : { expected_fee_amount: feeAmount(expectedFeeAmount) }
|
|
309
|
+
return accountRequest(`/commerce/bookings/${id}/cancel`, { method: "POST", body, signal }, value => checked(value, result => result.api_version === 1 && result.booking_id === id && result.status === "cancelled" && result.canceled === true && (result.cancellation_quote === null || validQuote(result.cancellation_quote))), true)
|
|
310
|
+
},
|
|
311
|
+
bookAppointmentVisit(input, { signal } = {}) {
|
|
312
|
+
const body = visitInput(input)
|
|
313
|
+
return accountRequest("/commerce/visits", { method: "POST", body, signal }, value => checked(value, result => validVisitResult(result, body)), true)
|
|
314
|
+
},
|
|
315
|
+
getAppointmentVisit(input, { signal } = {}) {
|
|
316
|
+
const body = visitInput(input)
|
|
317
|
+
return accountRequest(`/commerce/visits/${body.source_request_id}`, { signal }, value => checked(value, result => validVisitResult(result, body)), true)
|
|
318
|
+
},
|
|
319
|
+
getProfile({ signal } = {}) { return accountRequest("/profile", { signal, query: { view: "basic" } }, profile) },
|
|
246
320
|
updateProfile(changes, { signal } = {}) {
|
|
247
321
|
const allowed = new Set(["first_name", "last_name", "phone", "birth_date"])
|
|
248
322
|
if (!object(changes) || !Object.keys(changes).length || Object.entries(changes).some(([key, value]) =>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Reviewed public wire checks. No private package or server imports.
|
|
2
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
3
|
+
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value)
|
|
4
|
+
const integer = (value, min = 0, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(value) && value >= min && value <= max
|
|
5
|
+
const text = (value, max, min = 0) => typeof value === 'string' && value.length >= min && value.length <= max
|
|
6
|
+
const timestamp = value => text(value, 64) && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value) && Number.isFinite(Date.parse(value)) && new Date(`${value.slice(0, 10)}T00:00:00Z`).toISOString().slice(0, 10) === value.slice(0, 10)
|
|
7
|
+
const exact = (value, allowed) => object(value) && Object.keys(value).every(key => allowed.includes(key))
|
|
8
|
+
const selectionKeys = ['service_offering_id', 'starts_at', 'ends_at', 'credit_cost']
|
|
9
|
+
const selection = value => object(value) && uuid(value.service_offering_id) && timestamp(value.starts_at) && timestamp(value.ends_at) && integer(value.credit_cost, 1, 100_000) && Date.parse(value.ends_at) > Date.parse(value.starts_at) && Date.parse(value.ends_at) - Date.parse(value.starts_at) <= 86_400_000
|
|
10
|
+
const check = (condition, message = 'Invalid member request.') => { if (!condition) throw new TypeError(message) }
|
|
11
|
+
|
|
12
|
+
export function bookingInput(value) {
|
|
13
|
+
const fields = ['service_session_id', 'entitlement_id', 'source_request_id', 'policy_version', 'policy_consent', 'payment_authorized', 'payment_authorization_id']
|
|
14
|
+
check(exact(value, fields))
|
|
15
|
+
const result = { ...value }
|
|
16
|
+
for (const key of ['service_session_id', 'source_request_id', 'entitlement_id']) {
|
|
17
|
+
if (key === 'entitlement_id' && result[key] == null) continue
|
|
18
|
+
check(typeof result[key] === 'string')
|
|
19
|
+
result[key] = result[key].trim().toLowerCase()
|
|
20
|
+
check(uuid(result[key]))
|
|
21
|
+
}
|
|
22
|
+
const policy = fields.slice(3)
|
|
23
|
+
if (policy.some(key => key in result)) check(uuid(result.policy_version) && result.policy_consent === true && result.payment_authorized === true && uuid(result.payment_authorization_id))
|
|
24
|
+
return result
|
|
25
|
+
}
|
|
26
|
+
export function visitInput(value) {
|
|
27
|
+
check(exact(value, ['source_request_id', 'appointments']) && uuid(value.source_request_id) && Array.isArray(value.appointments) && value.appointments.length >= 1 && value.appointments.length <= 10)
|
|
28
|
+
let previousEnd = null
|
|
29
|
+
const appointments = value.appointments.map(item => {
|
|
30
|
+
check(exact(item, selectionKeys) && selection(item) && (previousEnd === null || Date.parse(item.starts_at) >= previousEnd))
|
|
31
|
+
previousEnd = Date.parse(item.ends_at)
|
|
32
|
+
return { ...item }
|
|
33
|
+
})
|
|
34
|
+
return { source_request_id: value.source_request_id, appointments }
|
|
35
|
+
}
|
|
36
|
+
export function bookingId(value) { check(uuid(value), 'Use a valid booking ID.'); return value }
|
|
37
|
+
export function feeAmount(value) { check(integer(value), 'Use a nonnegative fee amount in minor currency units.'); return value }
|
|
38
|
+
export function validBookingResult(value) {
|
|
39
|
+
return object(value) && value.api_version === 1 && object(value.booking) && uuid(value.booking.id) && ['confirmed', 'waitlisted', 'cancelled', 'attended', 'no_show'].includes(value.booking.status) && object(value.redemption) && uuid(value.redemption.id) && ['waitlisted', 'reserved', 'consumed', 'released', 'reversed'].includes(value.redemption.status)
|
|
40
|
+
}
|
|
41
|
+
export function validQuote(quote) {
|
|
42
|
+
if (!object(quote) || !(quote.policy_version === null || uuid(quote.policy_version)) || !integer(quote.fee_amount) || !/^[a-z]{3}$/u.test(quote.currency ?? '') || !timestamp(quote.quoted_at) ||
|
|
43
|
+
!['can_cancel', 'late', 'grace_applies', 'demand_observed', 'waive_if_filled', 'credit_return'].every(key => typeof quote[key] === 'boolean') ||
|
|
44
|
+
!['late_cancellation_deadline', 'grace_until'].every(key => quote[key] === null || timestamp(quote[key])) ||
|
|
45
|
+
!['no_policy', 'fees_disabled', 'legacy_booking', 'on_time', 'waitlisted', 'booking_grace', 'quiet_class', 'late_fee_if_unfilled', 'seat_refilled', 'teacher_assignment', 'started'].includes(quote.reason)) return false
|
|
46
|
+
if (quote.fee_amount > 0 && (!quote.can_cancel || !quote.late || quote.grace_applies || !quote.policy_version || quote.reason !== 'late_fee_if_unfilled')) return false
|
|
47
|
+
return !quote.grace_applies || Boolean(quote.grace_until)
|
|
48
|
+
}
|
|
49
|
+
export function validVisitResult(value, request) {
|
|
50
|
+
if (!object(value) || value.api_version !== 1 || !uuid(value.visit_id) || value.source_request_id !== request.source_request_id || !integer(value.credit_cost, 1) || !Array.isArray(value.bookings) || value.bookings.length !== request.appointments.length) return false
|
|
51
|
+
const bookingIds = new Set(), sessionIds = new Set()
|
|
52
|
+
let total = 0
|
|
53
|
+
for (const [index, booking] of value.bookings.entries()) {
|
|
54
|
+
const expected = request.appointments[index]
|
|
55
|
+
if (!selection(booking) || !uuid(booking.booking_id) || !uuid(booking.service_session_id) || !['confirmed', 'cancelled', 'attended', 'no_show'].includes(booking.status) || bookingIds.has(booking.booking_id) || sessionIds.has(booking.service_session_id) || booking.service_offering_id !== expected.service_offering_id || Date.parse(booking.starts_at) !== Date.parse(expected.starts_at) || Date.parse(booking.ends_at) !== Date.parse(expected.ends_at) || booking.credit_cost !== expected.credit_cost) return false
|
|
56
|
+
bookingIds.add(booking.booking_id); sessionIds.add(booking.service_session_id); total += booking.credit_cost
|
|
57
|
+
}
|
|
58
|
+
return total === value.credit_cost
|
|
59
|
+
}
|
|
60
|
+
const location = value => object(value) && /^[a-z]{8}$/u.test(value.public_id ?? '') && text(value.name, 160, 1) && text(value.timezone, 100, 1) && ['city', 'region'].every(key => value[key] === null || text(value[key], 4000))
|
|
61
|
+
export function availabilityRange({ from, to }) {
|
|
62
|
+
check(timestamp(from) && timestamp(to) && Date.parse(to) > Date.parse(from) && Date.parse(to) - Date.parse(from) <= 14 * 86_400_000, 'Choose RFC 3339 timestamps spanning at most 14 days.')
|
|
63
|
+
return { from: new Date(from).toISOString(), to: new Date(to).toISOString() }
|
|
64
|
+
}
|
|
65
|
+
export function validAvailability(value, range) {
|
|
66
|
+
if (!object(value) || value.api_version !== 1 || !timestamp(value.generated_at) || !object(value.range) || value.range.from !== range.from || value.range.to !== range.to || typeof value.has_more !== 'boolean' || !Array.isArray(value.slots) || value.slots.length > 20_000) return false
|
|
67
|
+
const ids = new Set()
|
|
68
|
+
for (const slot of value.slots) {
|
|
69
|
+
if (!selection(slot) || !uuid(slot.service_id) || !integer(slot.duration_minutes, 1, 1440) || Date.parse(slot.ends_at) - Date.parse(slot.starts_at) !== slot.duration_minutes * 60_000 || !location(slot.location) || !object(slot.service) || slot.service.id !== slot.service_id || !text(slot.service.name, 160, 1) || !(slot.service.description === null || text(slot.service.description, 4000)) || slot.candidate_id !== `appointment:${slot.service_offering_id}:${slot.duration_minutes}:${Date.parse(slot.starts_at)}` || Date.parse(slot.starts_at) < Date.parse(range.from) || Date.parse(slot.starts_at) >= Date.parse(range.to) || ids.has(slot.candidate_id)) return false
|
|
70
|
+
ids.add(slot.candidate_id)
|
|
71
|
+
}
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
export function validBookingCatalog(value) {
|
|
75
|
+
if (!object(value) || value.api_version !== 1 || !timestamp(value.generated_at) || !object(value.experience) || !['classes', 'appointments'].every(key => typeof value.experience[key] === 'boolean') || !['classes', 'appointments'].includes(value.experience.default_view) || !Array.isArray(value.locations) || value.locations.length > 1000 || !value.locations.every(location) || !Array.isArray(value.services) || value.services.length > 1000 || (value.cancellation_notice_minutes !== undefined && !integer(value.cancellation_notice_minutes, 0, 10080))) return false
|
|
76
|
+
const locations = new Set(value.locations.map(item => item.public_id)), services = new Set(), offerings = new Set()
|
|
77
|
+
if (locations.size !== value.locations.length || value.experience.appointments !== (value.services.length > 0) || (value.experience.classes || value.experience.appointments) && !value.experience[value.experience.default_view]) return false
|
|
78
|
+
for (const service of value.services) {
|
|
79
|
+
if (!object(service) || !uuid(service.id) || services.has(service.id) || !text(service.name, 160, 1) || !(service.description === null || text(service.description, 4000)) || !Array.isArray(service.offerings) || service.offerings.length < 1 || service.offerings.length > 1000) return false
|
|
80
|
+
services.add(service.id)
|
|
81
|
+
for (const offering of service.offerings) {
|
|
82
|
+
if (!object(offering) || !uuid(offering.id) || offerings.has(offering.id) || !integer(offering.duration_minutes, 1, 1440) || !locations.has(offering.location_public_id) || !(offering.credit_cost === null || integer(offering.credit_cost, 1, 100000))) return false
|
|
83
|
+
offerings.add(offering.id)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
export function accountInput({ mode, email, signupIntent }) {
|
|
89
|
+
check(['signup', 'recovery'].includes(mode) && typeof email === 'string' && email.trim().includes('@') && email.length <= 320 && !/[\u0000-\u001f\u007f]/u.test(email), 'Provide a valid account mode and email address.')
|
|
90
|
+
if (mode === 'signup') check(['new', 'migrate', 'auto', 'activate'].includes(signupIntent), 'Choose a signup intent explicitly.')
|
|
91
|
+
}
|
|
92
|
+
export function displayName(value) { check(text(value, 160, 1) && Boolean(value.trim()) && !/[\u0000-\u001f\u007f]/u.test(value), 'Provide a display name.'); return value }
|
|
93
|
+
export function emailCode(value) { check(typeof value === 'string' && /^(?:\d{6}|\d{8})$/u.test(value.trim()), 'Provide the email code from your message.'); return value.trim() }
|
|
94
|
+
export function validCapability(value) { return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/u.test(value) }
|
|
95
|
+
export function accountResolution(value) {
|
|
96
|
+
check(object(value) && ['existing_account', 'existing_profile', 'new_profile'].includes(value.kind) && text(value.display_name, 160, 1) && Boolean(value.display_name.trim()) && !/[\u0000-\u001f\u007f]/u.test(value.display_name) && text(value.masked_email, 320) && /^[^@\s*]\*{3}@[^@\s*]\*{3}(?:\.[a-z0-9.-]+)?$/iu.test(value.masked_email) && (value.masked_phone === null || /^\*{3} \*{3} [0-9]{4}$/u.test(value.masked_phone ?? '')))
|
|
97
|
+
return { kind: value.kind, display_name: value.display_name, masked_email: value.masked_email, masked_phone: value.masked_phone }
|
|
98
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Generated from reviewed API contracts. Run npm run api:generate; do not edit.
|
|
2
|
+
export interface OrganizationReadInputs {
|
|
3
|
+
"organization.read": Record<string, never>
|
|
4
|
+
"locations.read": Record<string, never>
|
|
5
|
+
"services.read": Record<string, never>
|
|
6
|
+
"schedule.read": { "week"?: string; "month"?: string }
|
|
7
|
+
"users.directory.read": { "q"?: string; "limit"?: number; "offset"?: number; "facet"?: "all" | "customers"; "customer_status"?: "active" | "inactive" | "archived" }
|
|
8
|
+
"task_lists.read": Record<string, never>
|
|
9
|
+
"inventory.read": Record<string, never>
|
|
10
|
+
}
|
|
11
|
+
export interface OrganizationReadResults {
|
|
12
|
+
"organization.read": { "api_version": 1; "data": { "organization": { "id": string; "slug": string; "name": string; "timezone": string; "currency_code": string; "brand_name"?: (string | null); [key: string]: unknown }; "access": { [key: string]: unknown } } }
|
|
13
|
+
"locations.read": { "api_version": 1; "data": { "locations": Array<{ "id": string; "name": string; "timezone": string; "status": string; "formatted_address"?: (string | null); [key: string]: unknown }> } }
|
|
14
|
+
"services.read": { "api_version": 1; "data": { "services": Array<{ "id": string; "name": string; "description"?: (string | null); "duration_minutes": number; "default_capacity"?: (number | null); "status": "active" | "inactive"; [key: string]: unknown }>; "past_session_variations": Array<{ [key: string]: unknown }> } }
|
|
15
|
+
"schedule.read": { "api_version": 1; "data": { "week_start": string; "sessions": Array<{ [key: string]: unknown }>; "staffing_windows": Array<{ [key: string]: unknown }>; "staffing_load_error": (string | null); "calendar": { "month": string; "days": Array<{ [key: string]: unknown }>; [key: string]: unknown } } }
|
|
16
|
+
"users.directory.read": { "api_version": 1; "data": { "users": Array<{ "id": string; "display_name": (string | null); "email"?: (string | null); "phone_e164"?: (string | null); "is_customer"?: boolean; "is_staff"?: boolean; "has_workspace_access"?: boolean; "person_status"?: "active" | "archived"; "customer_status"?: ("active" | "inactive" | "archived" | null) }>; "filters": { "facet": "all" | "customers"; "customer_status": ("active" | "inactive" | "archived" | null) }; "pagination": { "total": number; "limit": number; "offset": number; "has_more": boolean; "query": string } } }
|
|
17
|
+
"task_lists.read": { "api_version": 1; "data": { "task_lists": Array<{ [key: string]: unknown }>; "locations": Array<{ [key: string]: unknown }>; "can_manage_organization": boolean; [key: string]: unknown } }
|
|
18
|
+
"inventory.read": { "api_version": 1; "data": { "inventory": { "locations": Array<{ [key: string]: unknown }>; "stocks": Array<{ [key: string]: unknown }>; "alerts": Array<{ [key: string]: unknown }>; "movements": Array<{ [key: string]: unknown }>; "currency_code": string; [key: string]: unknown } } }
|
|
19
|
+
}
|
|
20
|
+
export type OrganizationReadOperationId = keyof OrganizationReadInputs
|
|
21
|
+
export interface OrganizationWriteInputs {
|
|
22
|
+
"messages.send": { "path"?: Record<string, never>; "body": { "channel_id": string; "body": string; "attachment_ids"?: Array<string>; "title"?: string; "preview"?: unknown; "action"?: unknown } }
|
|
23
|
+
"messages.read_state.update": { "path": { "channel_id": string }; "body": { "message_id": string } }
|
|
24
|
+
"messages.mute.update": { "path": { "channel_id": string }; "body": { "is_muted": boolean } }
|
|
25
|
+
}
|
|
26
|
+
export interface OrganizationWriteResults {
|
|
27
|
+
"messages.send": { "api_version": 1; "message": { "attachments"?: Array<{ "file_name": string; "content_type": "image/jpeg" | "image/png" | "image/webp" | "image/gif" | "image/heic" | "application/pdf" | "text/plain" | "text/csv" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | "application/vnd.openxmlformats-officedocument.presentationml.presentation"; "byte_size": number; "id": string }>; "id": string; "body": string; "kind": "announcement" | "deleted" | "notification"; "notification": ({ "source_kind": "service_session_work_slot" | "service_session_provider" | "staffing_window_assignment" | "staffing_window"; "source_id": string; "session_id": (string | null); "session_starts_at": (string | null); "staffing_window_id": (string | null); "starts_at": string; "time_zone": string; "status": "open" | "claimed" | "withdrawn" | "cancelled"; "label": string } | null); "author_name": string; "is_mine": boolean; "created_at": string; "edited_at": (string | null); "deleted_at": (string | null) } }
|
|
28
|
+
"messages.read_state.update": { "api_version": 1; "state": { "channel_id": string; "message_id": string; "read_at": string } }
|
|
29
|
+
"messages.mute.update": { "api_version": 1; "state": { "channel_id": string; "is_muted": boolean } }
|
|
30
|
+
}
|
|
31
|
+
export type OrganizationWriteOperationId = keyof OrganizationWriteInputs
|