slotlock 0.7.0 → 0.9.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +281 -0
  2. package/agent-server.d.ts +25 -5
  3. package/agent-server.js +152 -7
  4. package/agent-store-backend.d.ts +6 -1
  5. package/agent-store-backend.js +861 -55
  6. package/application-api.d.ts +19 -1
  7. package/application-api.js +578 -21
  8. package/approvals.d.ts +2 -0
  9. package/approvals.js +12 -5
  10. package/assistant.d.ts +64 -9
  11. package/assistant.js +450 -82
  12. package/availability-store.d.ts +193 -0
  13. package/availability-store.js +558 -0
  14. package/booking-rules.d.ts +92 -5
  15. package/booking-rules.js +243 -15
  16. package/dashboard-assistant-ui.d.ts +21 -0
  17. package/dashboard-assistant-ui.js +539 -0
  18. package/dashboard-markdown.d.ts +2 -0
  19. package/dashboard-markdown.js +157 -0
  20. package/dashboard-settings-ui.d.ts +1 -0
  21. package/dashboard-settings-ui.js +78 -0
  22. package/dashboard.d.ts +84 -11
  23. package/dashboard.js +1654 -384
  24. package/ddl.d.ts +5 -1
  25. package/ddl.js +760 -9
  26. package/event-feed.d.ts +1 -1
  27. package/event-feed.js +18 -0
  28. package/holiday-import.d.ts +38 -0
  29. package/holiday-import.js +57 -0
  30. package/holidays.d.ts +38 -0
  31. package/holidays.js +150 -0
  32. package/hours.d.ts +125 -0
  33. package/hours.js +224 -0
  34. package/index.d.ts +14 -3
  35. package/index.js +10 -2
  36. package/interop.d.ts +25 -7
  37. package/interop.js +145 -17
  38. package/lifecycle.d.ts +31 -0
  39. package/lifecycle.js +115 -0
  40. package/maintenance.d.ts +19 -0
  41. package/maintenance.js +55 -1
  42. package/metrics.js +3 -0
  43. package/openapi.json +16237 -7795
  44. package/package.json +1 -1
  45. package/ranges.d.ts +28 -0
  46. package/ranges.js +135 -0
  47. package/resource-search.d.ts +19 -0
  48. package/resource-search.js +153 -0
  49. package/rest-api.js +7 -0
  50. package/rules.d.ts +2 -0
  51. package/rules.js +2 -0
  52. package/self-host.d.ts +1 -1
  53. package/self-host.js +221 -21
  54. package/slotlock.manifest.json +1 -1
  55. package/store.d.ts +367 -24
  56. package/store.js +1567 -97
  57. package/types.d.ts +198 -0
@@ -13,7 +13,7 @@ import { z } from 'zod';
13
13
  import { SLOTLOCK_APPROVAL_KINDS, SLOTLOCK_MAX_APPROVAL_MINUTES, SLOTLOCK_MAX_APPROVAL_POLICIES, } from './approvals.js';
14
14
  import { SLOTLOCK_PARTNER_ACCOUNT, SLOTLOCK_PARTNER_PLAN } from './partner-names.js';
15
15
  import { SLOTLOCK_EVENT_OUTPUT } from './event-schema.js';
16
- import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
16
+ import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX } from './rules.js';
17
17
  import { SLOTLOCK_GROUPING_REF, SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_LABEL, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
18
18
  /** The scope an application operation needs, but the partner API's (`slotlockApplicationScope`). */
19
19
  export const SLOTLOCK_APPLICATION_SCOPE = 'admin';
@@ -162,6 +162,10 @@ const AVAILABILITY_RULE = z
162
162
  })
163
163
  .strict();
164
164
  const AVAILABILITY_RULES = z.array(AVAILABILITY_RULE).max(50);
165
+ /** A window of a day: minutes after local midnight and how long (it may run past midnight). */
166
+ const DAY_WINDOW = z
167
+ .object({ start_minutes: z.number().int().min(0).max(1_439), duration_minutes: z.number().int().min(1).max(1_440) })
168
+ .strict();
165
169
  /** What a booking on a resource must satisfy; each rule optional (`SlotlockBookingRules`). */
166
170
  const BOOKING_RULES = z
167
171
  .object({
@@ -170,10 +174,39 @@ const BOOKING_RULES = z
170
174
  slot_step_minutes: z.number().int().min(1).max(1_440).optional(),
171
175
  step_offset_minutes: z.number().int().min(0).max(1_439).optional(),
172
176
  buffer_before_minutes: z.number().int().min(0).max(1_440).optional(),
173
- turnaround_minutes: z.number().int().min(0).max(1_440).optional(),
177
+ turnaround_minutes: z.number().int().min(0).max(10_080).optional(),
174
178
  enforce_hours: z.boolean().optional(),
175
179
  check_in_minutes: z.number().int().min(1).max(1_440).optional(),
176
180
  require_certain: z.boolean().optional(),
181
+ /** A booking lasts at least, and at most, this many minutes (RFC 0004 B3). */
182
+ min_duration_minutes: z.number().int().min(1).max(525_600).optional(),
183
+ max_duration_minutes: z.number().int().min(1).max(525_600).optional(),
184
+ /** A booking lasts exactly one of these many minutes. */
185
+ allowed_durations: z.array(z.number().int().min(1).max(525_600)).min(1).max(20).optional(),
186
+ /** The length a search offers when it names none. */
187
+ default_duration_minutes: z.number().int().min(1).max(525_600).optional(),
188
+ /** A booking from one local day to another starts in one of these windows of the day. */
189
+ pickup_times: z.array(DAY_WINDOW).min(1).max(4).optional(),
190
+ /** And ends in one of these. */
191
+ return_times: z.array(DAY_WINDOW).min(1).max(4).optional(),
192
+ /**
193
+ * How the hours hold a booking from one local day to another: whole (all of it inside them) or
194
+ * ends (only its start and end); by default ends with pickup_times or return_times, else whole.
195
+ * Writes under enforce_hours, and every search, follow it.
196
+ */
197
+ hours_apply: z.enum(['whole', 'ends']).optional(),
198
+ /** Cancelling, or moving, is refused this many minutes before the start (RFC 0004 B5). */
199
+ cancel_cutoff_minutes: z.number().int().min(0).max(525_600).optional(),
200
+ reschedule_cutoff_minutes: z.number().int().min(0).max(525_600).optional(),
201
+ /** What a cancellation costs (within within_minutes of the start, when given), for you to charge. */
202
+ cancellation_fee: z
203
+ .object({
204
+ amount_minor: z.number().int().min(0).max(1_000_000_000_000),
205
+ currency: z.string().regex(/^[A-Z]{3}$/),
206
+ within_minutes: z.number().int().min(0).max(525_600).optional(),
207
+ })
208
+ .strict()
209
+ .optional(),
177
210
  })
178
211
  .strict();
179
212
  /** Default booking rules for the tenant or a pool: a resource's own rules but check-in. */
@@ -231,6 +264,7 @@ const APPROVAL_POLICY = z
231
264
  outcome: z.enum(['require_approval', 'refuse']),
232
265
  approval_ttl_minutes: z.number().int().min(1).max(SLOTLOCK_MAX_APPROVAL_MINUTES),
233
266
  lead_minutes: z.number().int().min(0).max(SLOTLOCK_MAX_APPROVAL_MINUTES),
267
+ include_application: z.boolean(),
234
268
  created_at: DATE_TIME,
235
269
  updated_at: DATE_TIME,
236
270
  })
@@ -315,8 +349,73 @@ const RESOURCE = z
315
349
  parent_id: z.string().uuid().nullable(),
316
350
  /** Its place among its parent's parts, from 0; `null` when it is no part. */
317
351
  part_index: z.number().int().min(0).nullable(),
352
+ /**
353
+ * The hours it inherits when it has none of its own (RFC 0004 B2): its location's (the nearest
354
+ * up the chain), else the tenant's; `null` when it has its own or uses the server's default.
355
+ */
356
+ inherited_availability: z
357
+ .object({ source: z.enum(['location', 'tenant']), location: GROUPING_REF.nullable(), rules: AVAILABILITY_RULES })
358
+ .strict()
359
+ .nullable(),
318
360
  })
319
361
  .strict();
362
+ /** An admin's reason for going past a cancel or reschedule cutoff (RFC 0004 B5), kept on the feed. */
363
+ const OVERRIDE_REASON = z.string().trim().min(1).max(500);
364
+ /** A cancellation's fee as recorded: an amount in the currency's minor unit, ISO 4217 currency. */
365
+ const RECORDED_FEE = z.object({ amount_minor: z.number().int().min(0), currency: z.string().regex(/^[A-Z]{3}$/) }).strict();
366
+ /** A day in the scope's calendar, YYYY-MM-DD. */
367
+ const LOCAL_DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
368
+ /** One window of an exception's day: minutes after local midnight and how long. */
369
+ const EXCEPTION_WINDOW = z
370
+ .object({ start_minutes: z.number().int().min(0).max(1_439), duration_minutes: z.number().int().min(1).max(1_440) })
371
+ .strict();
372
+ /** A closure or a day of special hours (RFC 0004 B1). */
373
+ const AVAILABILITY_EXCEPTION = z
374
+ .object({
375
+ id: z.string().uuid(),
376
+ /** The location it applies to (and every location inside it); `null` when not one location's. */
377
+ location: GROUPING_REF.nullable(),
378
+ /** The resource it applies to; `null` when not one resource's. Both null: the whole tenant. */
379
+ resource_id: z.string().uuid().nullable(),
380
+ starts_on: LOCAL_DATE,
381
+ /** The last day, inclusive. */
382
+ ends_on: LOCAL_DATE,
383
+ outcome: z.enum(['closed', 'open']),
384
+ /** For open: the windows each of its days has instead of its weekly hours; `null` when closed. */
385
+ hours: z.array(EXCEPTION_WINDOW).min(1).max(4).nullable(),
386
+ label: z.string().max(200),
387
+ /** manual, ics (a holiday calendar) or vavailability (a resource's imported hours). */
388
+ source: z.enum(['manual', 'ics', 'vavailability']),
389
+ /** The holiday calendar it was imported from (slotlock_list_holiday_calendars); `null` when none. */
390
+ calendar_id: z.string().uuid().nullable(),
391
+ created_at: DATE_TIME,
392
+ })
393
+ .strict();
394
+ /** A holiday calendar imported into closures (RFC 0004 B1). */
395
+ const HOLIDAY_CALENDAR = z
396
+ .object({
397
+ calendar_id: z.string().uuid(),
398
+ /** Unique at its scope: importing under the same name replaces what that calendar said. */
399
+ name: z.string().min(1).max(200),
400
+ location: GROUPING_REF.nullable(),
401
+ resource_id: z.string().uuid().nullable(),
402
+ /** A subscription's URL, imported again every 30 days; `null` for one pasted or uploaded. */
403
+ url: z.string().max(2_048).nullable(),
404
+ imported_at: DATE_TIME.nullable(),
405
+ next_import_at: DATE_TIME.nullable(),
406
+ /** Why a subscription's last import failed; it is tried again a day later. */
407
+ last_error: z.string().max(500).nullable(),
408
+ /** Its closures. */
409
+ closures: z.number().int().min(0),
410
+ created_at: DATE_TIME,
411
+ })
412
+ .strict();
413
+ const AVAILABILITY_DEFAULTS_RESULT = {
414
+ /** Resources in use the hours now apply to. */
415
+ resources_updated: z.number().int().min(0),
416
+ /** Resources in use the hours reach that keep hours of their own. */
417
+ resources_with_own_hours: z.number().int().min(0),
418
+ };
320
419
  const HISTORY_ENTRY = z
321
420
  .object({
322
421
  id: z.string().max(40),
@@ -389,12 +488,29 @@ const WAITLIST_ENTRY = z
389
488
  end: DATE_TIME,
390
489
  holder_ref: z.string().min(1).max(500),
391
490
  offer_ttl_seconds: z.number().int().min(60).max(86_400),
392
- /** waiting; offered (hold_id holds the window on offered_resource_id); expired (it started first). */
393
- status: z.enum(['waiting', 'offered', 'expired']),
491
+ /**
492
+ * waiting; offered (hold_id holds the time on offered_resource_id); then accepted (the hold was
493
+ * confirmed), declined (let go) or lapsed (it expired); or expired (no slot could be offered in
494
+ * the window).
495
+ */
496
+ status: z.enum(['waiting', 'offered', 'accepted', 'declined', 'lapsed', 'expired']),
394
497
  hold_id: z.string().uuid().nullable(),
395
498
  offered_resource_id: RESOURCE_ID.nullable(),
396
499
  offered_at: DATE_TIME.nullable(),
397
500
  created_at: DATE_TIME,
501
+ /** The length it waits for, any slot of it within the window; null: the whole window. */
502
+ duration_minutes: z.number().int().min(1).nullable(),
503
+ /** When its offer was accepted, declined or lapsed. */
504
+ decided_at: DATE_TIME.nullable(),
505
+ })
506
+ .strict();
507
+ /** A payment's reference at the integrator's processor (RFC 0004 C3): a PaymentIntent id, say. */
508
+ const PAYMENT_REF = z.string().regex(/^[\x21-\x7e]{1,255}$/, 'must be 1-255 printable ASCII characters');
509
+ /** An amount in its currency's minor unit (cents), and the ISO 4217 code, upper case. */
510
+ const PAYMENT_AMOUNT = z
511
+ .object({
512
+ amount_minor: z.number().int().min(0).max(1_000_000_000_000),
513
+ currency: z.string().regex(/^[A-Z]{3}$/, 'must be an ISO 4217 code in capitals, like GBP'),
398
514
  })
399
515
  .strict();
400
516
  const HOLD = z
@@ -405,6 +521,36 @@ const HOLD = z
405
521
  end: DATE_TIME,
406
522
  expires_at: DATE_TIME,
407
523
  source: z.string().max(200).nullable(),
524
+ /** Present when it waits for payment (RFC 0004 C3), with the payment once recorded. */
525
+ payment_required: z.literal(true).optional(),
526
+ payment_ref: PAYMENT_REF.nullable().optional(),
527
+ paid_at: DATE_TIME.nullable().optional(),
528
+ /** What the payment must be, when the hold names it; and what was received, once paid. */
529
+ payment_amount: PAYMENT_AMOUNT.nullable().optional(),
530
+ paid_amount: PAYMENT_AMOUNT.nullable().optional(),
531
+ })
532
+ .strict();
533
+ /** A person a booking may name to be told about it (RFC 0004 P3), by the tenant's reference. */
534
+ const CONTACT_REF = z
535
+ .string()
536
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/, 'must be 1-200 letters, digits, . _ : or -, starting with a letter or digit');
537
+ const E164_NUMBER = z.string().regex(/^\+[1-9][0-9]{6,14}$/, 'must be an E.164 number, like +447700900123');
538
+ const CONSENT = z
539
+ .object({ email: z.boolean().optional(), sms: z.boolean().optional(), whatsapp: z.boolean().optional() })
540
+ .strict();
541
+ const CONTACT = z
542
+ .object({
543
+ contact_ref: CONTACT_REF,
544
+ name: z.string().max(200).nullable(),
545
+ email: z.string().max(320).nullable(),
546
+ phone: E164_NUMBER.nullable(),
547
+ whatsapp: E164_NUMBER.nullable(),
548
+ consent: CONSENT,
549
+ /** When each answer in consent was given, kept while it stands. */
550
+ consent_at: z.object({ email: DATE_TIME.optional(), sms: DATE_TIME.optional(), whatsapp: DATE_TIME.optional() }).strict(),
551
+ locale: z.string().max(35).nullable(),
552
+ created_at: DATE_TIME,
553
+ updated_at: DATE_TIME,
408
554
  })
409
555
  .strict();
410
556
  const RESERVATION = z
@@ -417,6 +563,12 @@ const RESERVATION = z
417
563
  buffer_after_minutes: z.number().int().min(0),
418
564
  revision: REVISION,
419
565
  source: z.string().max(200).nullable(),
566
+ /** Whom to tell about it (RFC 0004 P3), when it names someone. */
567
+ contact_ref: CONTACT_REF.optional(),
568
+ /** Present, and false, when no one should be notified about it. */
569
+ notify: z.literal(false).optional(),
570
+ /** Minutes before its start it is reminded (reminder.due), latest first, when it is. */
571
+ reminder_minutes: z.array(z.number().int().min(0).max(527_040)).max(5).optional(),
420
572
  })
421
573
  .strict();
422
574
  /** A JSCalendar Event (RFC 8984 §5.1); interop.ts's JSCalendarEvent names every property. */
@@ -686,7 +838,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
686
838
  {
687
839
  name: 'slotlock_set_resource_availability',
688
840
  title: "Set a resource's bookable hours",
689
- description: "Give one resource its own weekly bookable hours. `null` makes it use the server's default; `[]` makes it never bookable. Each rule is weekly with BYDAY, in the resource's time zone: start_minutes after midnight, lasting duration_minutes.",
841
+ description: "Give one resource its own weekly bookable hours. `null` makes it inherit: its location's hours (the nearest up the chain), else the tenant's, else the server's; `[]` makes it never bookable. Each rule is weekly with BYDAY, in the resource's time zone: start_minutes after midnight, lasting duration_minutes.",
690
842
  method: 'PUT',
691
843
  path: '/v1/resources/{resource_id}/availability',
692
844
  input: z.object({ resource_id: RESOURCE_ID, rules: AVAILABILITY_RULES.nullable() }).strict(),
@@ -698,7 +850,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
698
850
  {
699
851
  name: 'slotlock_export_resource_availability',
700
852
  title: "Export a resource's bookable hours as iCalendar",
701
- description: "One resource's own weekly bookable hours as an iCalendar VAVAILABILITY (RFC 7953) for a calendar or scheduling tool: an AVAILABLE per rule, its DTSTART's TZID the resource's IANA time zone. `null` when the resource uses the server's default.",
853
+ description: "The weekly bookable hours that apply to one resource (its own, or those it inherits) as an iCalendar VAVAILABILITY (RFC 7953) for a calendar or scheduling tool: an AVAILABLE per rule, its DTSTART's TZID the resource's IANA time zone. The closures and special hours of the next year are there too: an EXDATE takes a weekly window off a day, and an AVAILABLE with RDATEs gives a day its own hours. `null` when the resource uses the server's default.",
702
854
  method: 'GET',
703
855
  path: '/v1/resources/{resource_id}/availability/ical',
704
856
  input: z.object({ resource_id: RESOURCE_ID }).strict(),
@@ -710,7 +862,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
710
862
  {
711
863
  name: 'slotlock_import_resource_availability',
712
864
  title: "Import a resource's bookable hours from iCalendar",
713
- description: "Set one resource's own weekly bookable hours from an iCalendar VAVAILABILITY (RFC 7953), as slotlock_set_resource_availability does from rules: each AVAILABLE a weekly RRULE with a start time and DTEND or DURATION. Times must be in the resource's time zone (its TZID) or floating. 400 invalid_availability names what weekly hours cannot say: a bounded VAVAILABILITY, RDATE, EXDATE, a single window, another zone.",
865
+ description: "Set one resource's own weekly bookable hours from an iCalendar VAVAILABILITY (RFC 7953), as slotlock_set_resource_availability does from rules: each AVAILABLE a weekly RRULE with a start time and DTEND or DURATION. EXDATE, RDATE and single AVAILABLEs become the resource's exceptions, replacing those an earlier import made: each day they name keeps the windows left on it, or is closed with none. Times must be in the resource's time zone (its TZID) or floating. 400 invalid_availability names what cannot be said: a bounded VAVAILABILITY, RECURRENCE-ID, another zone, more than four windows on a day.",
714
866
  method: 'PUT',
715
867
  path: '/v1/resources/{resource_id}/availability/ical',
716
868
  input: z
@@ -727,7 +879,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
727
879
  {
728
880
  name: 'slotlock_set_resource_booking_rules',
729
881
  title: "Set a resource's booking rules",
730
- description: "Set what a booking on one resource must satisfy from now on: minimum notice, how far ahead, the step starts fall on (from local midnight in the resource's time zone, offset by step_offset_minutes), the time kept free before and after each booking, and whether bookings must lie inside its hours. `null` clears them. A rule it does not set comes from its pool's or the tenant's defaults (slotlock_set_pool_booking_rules, slotlock_set_tenant_booking_rules). Holds, reservations, moves and agents' events are refused with the rule they break; searches offer only what they allow.",
882
+ description: "Set what a booking on one resource must satisfy from now on: minimum notice, how far ahead, the step starts fall on (from local midnight in the resource's time zone, offset by step_offset_minutes), the time kept free before and after each booking (turnaround up to a week), whether bookings must lie inside its hours, how long a booking may be (min_duration_minutes, max_duration_minutes, allowed_durations, and default_duration_minutes for a search that names no length), and for a booking from one local day to another, the pickup_times it starts in and the return_times it ends in. `null` clears them. A rule it does not set comes from its pool's or the tenant's defaults (slotlock_set_pool_booking_rules, slotlock_set_tenant_booking_rules). Holds, reservations, moves and agents' events are refused with the rule they break; searches offer only what they allow.",
731
883
  method: 'PUT',
732
884
  path: '/v1/resources/{resource_id}/booking-rules',
733
885
  input: z.object({ resource_id: RESOURCE_ID, rules: BOOKING_RULES.nullable() }).strict(),
@@ -872,20 +1024,163 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
872
1024
  },
873
1025
  {
874
1026
  name: 'slotlock_set_tenant_availability',
875
- title: "Set every resource's bookable hours",
876
- description: "Set the weekly bookable hours of every resource the tenant has, as slotlock_set_resource_availability does for one. `null` makes them all use the server's default. Returns how many resources changed.",
1027
+ title: "Set the tenant's bookable hours",
1028
+ description: "Store the tenant's weekly bookable hours: every resource without hours of its own, and with no location hours above it, uses them, resources added later too. `null` hands them back to the server's default. Nothing is copied: a resource with its own hours keeps them (override_resources none, the default), unless override_resources is all, which clears them so every resource inherits, or matching, which clears only those whose hours are the same as the tenant's were before or as these: the copies versions up to 0.8.0 made when setting the tenant's hours, while hours set apart on purpose stay. resources_updated counts the resources in use these hours now apply to; resources_with_own_hours, those that keep their own.",
877
1029
  method: 'PUT',
878
1030
  path: '/v1/availability',
879
- input: z.object({ rules: AVAILABILITY_RULES.nullable() }).strict(),
880
- output: z.object({ resources_updated: z.number().int().min(0) }).strict(),
1031
+ input: z
1032
+ .object({ rules: AVAILABILITY_RULES.nullable(), override_resources: z.enum(['none', 'matching', 'all']).optional() })
1033
+ .strict(),
1034
+ output: z.object(AVAILABILITY_DEFAULTS_RESULT).strict(),
881
1035
  risk: { readOnly: false, destructive: false, idempotent: true },
882
1036
  invoke: 'setTenantAvailability',
883
1037
  example: { rules: EXAMPLE_RULES },
884
1038
  },
1039
+ {
1040
+ name: 'slotlock_get_availability_defaults',
1041
+ title: 'Read stored bookable hours',
1042
+ description: "The tenant's stored weekly hours (`null` when it uses the server's default) and each location's that has its own. A resource without its own uses its location's, the nearest up the chain, then the tenant's, then the server's.",
1043
+ method: 'GET',
1044
+ path: '/v1/availability',
1045
+ input: z.object({}).strict(),
1046
+ output: z
1047
+ .object({
1048
+ tenant: AVAILABILITY_RULES.nullable(),
1049
+ locations: z.array(z.object({ location: GROUPING_REF, rules: AVAILABILITY_RULES }).strict()),
1050
+ })
1051
+ .strict(),
1052
+ risk: { readOnly: true, destructive: false, idempotent: true },
1053
+ invoke: 'getAvailabilityDefaults',
1054
+ example: {},
1055
+ },
1056
+ {
1057
+ name: 'slotlock_set_location_availability',
1058
+ title: "Set a location's bookable hours",
1059
+ description: "Store a location's weekly bookable hours, each read in a resource's own time zone: resources at it, or at a location inside it, without hours of their own use them, unless a location nearer them has its own. `null` clears them, so they inherit from further up. 404 location_not_found for a location the tenant does not have.",
1060
+ method: 'PUT',
1061
+ path: '/v1/locations/{location}/availability',
1062
+ input: z.object({ location: GROUPING_REF, rules: AVAILABILITY_RULES.nullable() }).strict(),
1063
+ output: z.object({ location: GROUPING_REF, ...AVAILABILITY_DEFAULTS_RESULT }).strict(),
1064
+ risk: { readOnly: false, destructive: false, idempotent: true },
1065
+ invoke: 'setLocationAvailability',
1066
+ example: { location: 'london-depot', rules: EXAMPLE_RULES },
1067
+ },
1068
+ {
1069
+ name: 'slotlock_list_availability_exceptions',
1070
+ title: 'List closures and special hours',
1071
+ description: "The tenant's exceptions to its weekly hours, by first day: with resource_id, those that apply to that resource (the tenant's, its locations' and its own); with location, that location's own; else all of them. from and to keep those that reach the days between. At most limit (1-1,000, default 100); next is the cursor for after.",
1072
+ method: 'GET',
1073
+ path: '/v1/availability/exceptions',
1074
+ input: z
1075
+ .object({
1076
+ resource_id: z.string().uuid().optional(),
1077
+ location: GROUPING_REF.optional(),
1078
+ from: LOCAL_DATE.optional(),
1079
+ to: LOCAL_DATE.optional(),
1080
+ limit: z.coerce.number().int().min(1).max(1_000).optional(),
1081
+ after: z.string().uuid().optional(),
1082
+ })
1083
+ .strict(),
1084
+ output: z.object({ exceptions: z.array(AVAILABILITY_EXCEPTION), next: z.string().uuid().nullable() }).strict(),
1085
+ risk: { readOnly: true, destructive: false, idempotent: true },
1086
+ invoke: 'listAvailabilityExceptions',
1087
+ example: { from: '2026-12-01', to: '2027-01-31' },
1088
+ },
1089
+ {
1090
+ name: 'slotlock_create_availability_exception',
1091
+ title: 'Add a closure or special hours',
1092
+ description: "Close whole days, or give them hours of their own, for the whole tenant, one location (and every location inside it) or one resource. starts_on to ends_on (inclusive, at most 367 days) are local days, read in each resource's time zone. outcome closed empties those days; open replaces their weekly hours with hours (1-4 windows of start_minutes and duration_minutes). Where several apply to a day, the nearest wins: the resource's own, then its location's from the innermost out, then the tenant's. Search leaves the days as they say; a write is refused outside_hours only under the enforce_hours booking rule. 409 availability_exceptions_limit_reached past 5,000 a tenant.",
1093
+ method: 'POST',
1094
+ path: '/v1/availability/exceptions',
1095
+ input: z
1096
+ .object({
1097
+ location: GROUPING_REF.optional(),
1098
+ resource_id: z.string().uuid().optional(),
1099
+ starts_on: LOCAL_DATE,
1100
+ ends_on: LOCAL_DATE.optional(),
1101
+ outcome: z.enum(['closed', 'open']),
1102
+ hours: z.array(EXCEPTION_WINDOW).min(1).max(4).optional(),
1103
+ label: z.string().trim().min(1).max(200),
1104
+ })
1105
+ .strict(),
1106
+ output: z.object({ exception: AVAILABILITY_EXCEPTION }).strict(),
1107
+ risk: { readOnly: false, destructive: false, idempotent: false },
1108
+ invoke: 'createAvailabilityException',
1109
+ example: { starts_on: '2026-12-25', ends_on: '2026-12-26', outcome: 'closed', label: 'Christmas' },
1110
+ },
1111
+ {
1112
+ name: 'slotlock_delete_availability_exception',
1113
+ title: 'Delete a closure or special hours',
1114
+ description: 'Remove an exception: its days read as the weekly hours again. 404 availability_exception_not_found.',
1115
+ method: 'DELETE',
1116
+ path: '/v1/availability/exceptions/{exception_id}',
1117
+ input: z.object({ exception_id: z.string().uuid() }).strict(),
1118
+ output: z.object({ deleted: z.boolean() }).strict(),
1119
+ risk: { readOnly: false, destructive: true, idempotent: true },
1120
+ invoke: 'deleteAvailabilityException',
1121
+ example: { exception_id: '7b3c1d9e-4f2a-4e8b-9c6d-1a2b3c4d5e6f' },
1122
+ },
1123
+ {
1124
+ name: 'slotlock_import_holidays',
1125
+ title: 'Import a holiday calendar',
1126
+ description: "Close the days an iCalendar holiday calendar names, for the whole tenant, one location or one resource: the text in icalendar, or an https url the server fetches (at most 1 MB, 10 seconds; private addresses refused). Each all-day event closes the days it covers, and each occurrence of a recurring one within from to to (default today to two years on, at most three years); timed and cancelled events are skipped. The calendar is kept by name at its scope (name, by default its own X-WR-CALNAME, else the url's host and path, else Holidays): importing it again updates its closures instead of adding more, and deletes the ones it made that it no longer names within the span (removed), so a holiday it dropped goes too. With subscribe (and a url), the server keeps the url and imports it again every 30 days. At most 50 calendars a tenant (409 holiday_calendars_limit_reached). 400 invalid_holiday_calendar for text that is not one; 400 holiday_calendar_unreachable when the url cannot be read; 400 holiday_url_not_served where the server fetches no urls.",
1127
+ method: 'POST',
1128
+ path: '/v1/availability/holidays',
1129
+ input: z
1130
+ .object({
1131
+ icalendar: z.string().min(1).max(1_048_576).optional(),
1132
+ url: z.string().url().max(2_000).optional(),
1133
+ name: z.string().trim().min(1).max(200).optional(),
1134
+ subscribe: z.boolean().optional(),
1135
+ location: GROUPING_REF.optional(),
1136
+ resource_id: z.string().uuid().optional(),
1137
+ from: LOCAL_DATE.optional(),
1138
+ to: LOCAL_DATE.optional(),
1139
+ })
1140
+ .strict(),
1141
+ output: z
1142
+ .object({
1143
+ /** Closures added or updated. */
1144
+ imported: z.number().int().min(0),
1145
+ /** Events that were not all-day, were cancelled, or fell outside the span. */
1146
+ skipped: z.number().int().min(0),
1147
+ /** Closures this calendar made before that it no longer names in the span, deleted. */
1148
+ removed: z.number().int().min(0),
1149
+ calendar: HOLIDAY_CALENDAR,
1150
+ })
1151
+ .strict(),
1152
+ risk: { readOnly: false, destructive: false, idempotent: true },
1153
+ invoke: 'importHolidays',
1154
+ example: { url: 'https://example.com/holidays/gb.ics', location: 'london-depot', subscribe: true },
1155
+ },
1156
+ {
1157
+ name: 'slotlock_list_holiday_calendars',
1158
+ title: 'List holiday calendars',
1159
+ description: "The holiday calendars imported into the tenant's closures (slotlock_import_holidays), each with its scope, how many closures it made, and for a subscription its url, when it was last imported, when it will be again, and why the last import failed if it did.",
1160
+ method: 'GET',
1161
+ path: '/v1/availability/holiday-calendars',
1162
+ input: z.object({}).strict(),
1163
+ output: z.object({ calendars: z.array(HOLIDAY_CALENDAR).max(50) }).strict(),
1164
+ risk: { readOnly: true, destructive: false, idempotent: true },
1165
+ invoke: 'listHolidayCalendars',
1166
+ example: {},
1167
+ },
1168
+ {
1169
+ name: 'slotlock_delete_holiday_calendar',
1170
+ title: 'Delete a holiday calendar',
1171
+ description: 'Delete a holiday calendar and every closure it made, and stop importing it again. deleted is false when the tenant has no such calendar; closures counts those deleted with it.',
1172
+ method: 'DELETE',
1173
+ path: '/v1/availability/holiday-calendars/{calendar_id}',
1174
+ input: z.object({ calendar_id: z.string().uuid() }).strict(),
1175
+ output: z.object({ deleted: z.boolean(), closures: z.number().int().min(0) }).strict(),
1176
+ risk: { readOnly: false, destructive: true, idempotent: true },
1177
+ invoke: 'deleteHolidayCalendar',
1178
+ example: { calendar_id: '3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d' },
1179
+ },
885
1180
  {
886
1181
  name: 'slotlock_acquire_hold',
887
1182
  title: 'Hold time',
888
- description: 'Occupy a window without booking it, for example while a customer pays. The hold blocks every other booking until it expires (the database clock, ttl_seconds from now), is confirmed, or is released. buffer_after_minutes keeps the time after it busy as well, and a confirmed hold keeps it. A taken window answers 409 overlap. With an idempotency key (the Idempotency-Key header or idempotency_key), a retry answers the hold the first request made, until that hold would have expired, is released or is confirmed; the same key with another request is 422 idempotency_conflict.',
1183
+ description: 'Occupy a window without booking it, for example while a customer pays. The hold blocks every other booking until it expires (the database clock, ttl_seconds from now), is confirmed, or is released. buffer_after_minutes keeps the time after it busy as well, and a confirmed hold keeps it. With payment_required (ttl_seconds at most 1800), it cannot be confirmed until slotlock_mark_hold_paid records the payment, which must be at least payment (amount_minor in the minor unit of its currency, and the currency) when given: Slotlock records payment state and never moves money. A taken window answers 409 overlap. With an idempotency key (the Idempotency-Key header or idempotency_key), a retry answers the hold the first request made, until that hold would have expired, is released or is confirmed; the same key with another request is 422 idempotency_conflict.',
889
1184
  method: 'POST',
890
1185
  path: '/v1/holds',
891
1186
  input: z
@@ -902,9 +1197,21 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
902
1197
  idempotency_key: IDEMPOTENCY_KEY.optional(),
903
1198
  // Last, so a generated 0.2.0 client's positional arguments keep their places.
904
1199
  buffer_after_minutes: BUFFER_AFTER_MINUTES.optional(),
1200
+ /** It waits for payment (RFC 0004 C3): confirmed only once slotlock_mark_hold_paid records it. */
1201
+ payment_required: z.boolean().optional(),
1202
+ /** What the payment must be: a smaller one, or in another currency, is refused. */
1203
+ payment: PAYMENT_AMOUNT.optional(),
905
1204
  })
906
1205
  .strict()
907
- .refine(boundedSpan, SPAN_MESSAGE),
1206
+ .refine(boundedSpan, SPAN_MESSAGE)
1207
+ .refine((input) => input.payment_required !== true || input.ttl_seconds <= 1_800, {
1208
+ message: 'A hold that waits for payment lives at most 1800 seconds',
1209
+ path: ['ttl_seconds'],
1210
+ })
1211
+ .refine((input) => input.payment === undefined || input.payment_required === true, {
1212
+ message: 'payment needs payment_required',
1213
+ path: ['payment'],
1214
+ }),
908
1215
  output: z.object({ hold: HOLD }).strict(),
909
1216
  risk: { readOnly: false, destructive: false, idempotent: false },
910
1217
  invoke: 'acquireHold',
@@ -946,7 +1253,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
946
1253
  {
947
1254
  name: 'slotlock_confirm_hold',
948
1255
  title: 'Confirm a hold',
949
- description: "Turn an unexpired hold into a confirmed reservation, whose reference is the hold's id at revision 1: move or cancel it with the reservation routes by that reference (DELETE /v1/reservations/{hold_id}?revision=2 cancels it). Confirming it again answers the same reservation, so a retry is safe. An expired hold answers 409 hold_expired; a released or unknown one, 404 hold_not_found.",
1256
+ description: "Turn an unexpired hold into a confirmed reservation, whose reference is the hold's id at revision 1: move or cancel it with the reservation routes by that reference (DELETE /v1/reservations/{hold_id}?revision=2 cancels it). Confirming it again answers the same reservation, so a retry is safe. An expired hold answers 409 hold_expired; a released or unknown one, 404 hold_not_found; one that waits for payment not yet recorded, 409 payment_required.",
950
1257
  method: 'POST',
951
1258
  path: '/v1/holds/{hold_id}/confirm',
952
1259
  input: z.object({ hold_id: HOLD_ID }).strict(),
@@ -955,6 +1262,29 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
955
1262
  invoke: 'confirmHold',
956
1263
  example: { hold_id: EXAMPLE_HOLD_ID },
957
1264
  },
1265
+ {
1266
+ name: 'slotlock_mark_hold_paid',
1267
+ title: 'Record a hold as paid',
1268
+ description: "Record that a hold made with payment_required is paid, by the payment's reference at your processor (a Stripe PaymentIntent id, say), once you have verified it: the hold may then be confirmed. Slotlock records payment state and never moves money. The same reference again answers the same hold (replayed: true), whatever became of it since (confirmed, booked as an event, expired): the payment is kept apart from the hold. Another reference answers 409 payment_conflict. When the hold names its payment, received must be at least that amount in that currency (409 payment_amount_mismatch). The feed says so (hold.paid). A hold that does not wait for payment answers 409 payment_not_required; one that expired before it was paid, 409 hold_expired (refund the payment: its time may be someone else's); one released before it was paid, or unknown, 404 hold_not_found.",
1269
+ method: 'POST',
1270
+ path: '/v1/holds/{hold_id}/paid',
1271
+ input: z
1272
+ .object({
1273
+ hold_id: HOLD_ID,
1274
+ payment_ref: PAYMENT_REF,
1275
+ /** What was received, as your processor says: required when the hold names its payment. */
1276
+ received: PAYMENT_AMOUNT.optional(),
1277
+ })
1278
+ .strict(),
1279
+ output: z.object({ hold: HOLD, replayed: z.boolean() }).strict(),
1280
+ risk: { readOnly: false, destructive: false, idempotent: true },
1281
+ invoke: 'markHoldPaid',
1282
+ example: {
1283
+ hold_id: EXAMPLE_HOLD_ID,
1284
+ payment_ref: 'pi_3PqX9a2eZvKYlo2C1d7Hk0Lm',
1285
+ received: { amount_minor: 4500, currency: 'GBP' },
1286
+ },
1287
+ },
958
1288
  {
959
1289
  name: 'slotlock_release_hold',
960
1290
  title: 'Release a hold',
@@ -1005,7 +1335,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1005
1335
  {
1006
1336
  name: 'slotlock_put_reservation',
1007
1337
  title: 'Book or move a reservation',
1008
- description: 'Book the reservation with this reference (revision 1), or move it (the next revision: the current one plus 1). Sending the same reservation at its current revision again changes nothing (replayed: true). A taken window answers 409 overlap and leaves a moved reservation where it was. buffer_after_minutes keeps the time after it busy as well.',
1338
+ description: 'Book the reservation with this reference (revision 1), or move it (the next revision: the current one plus 1). Sending the same reservation at its current revision again changes nothing (replayed: true). A taken window answers 409 overlap and leaves a moved reservation where it was. buffer_after_minutes keeps the time after it busy as well. A move past the resource\'s reschedule cutoff answers 409 booking_rule_reschedule_cutoff unless override_reason says why, which the feed keeps. local_start (YYYY-MM-DDTHH:MM in the resource\'s zone) records the wall time it is booked for: it must name start, and if time zone rules later move that wall time to another instant, maintenance says so (reservation.timezone_shifted) or follows it. 400 invalid_local_start otherwise. contact_ref names whom to tell about it (slotlock_put_contact) and notify: false says to tell no one; both are on the feed with each change, as sent with each revision. reminder_minutes (up to five, distinct) puts reminder.due on the feed that long before its start, once each, and again if it moves.',
1009
1339
  method: 'PUT',
1010
1340
  path: '/v1/reservations/{reservation_ref}',
1011
1341
  input: z
@@ -1017,6 +1347,16 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1017
1347
  buffer_after_minutes: BUFFER_AFTER_MINUTES.default(0),
1018
1348
  revision: REVISION,
1019
1349
  source: SOURCE.optional(),
1350
+ /** An admin's reason for moving it past its reschedule cutoff (RFC 0004 B5). */
1351
+ override_reason: OVERRIDE_REASON.optional(),
1352
+ /** The wall time it starts at in the resource's zone, kept across zone rule changes (RFC 0004 B9). */
1353
+ local_start: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/).optional(),
1354
+ /** Whom to tell about it (RFC 0004 P3): a contact's reference, never the person. */
1355
+ contact_ref: CONTACT_REF.optional(),
1356
+ /** False: no one should be notified about it (default true). */
1357
+ notify: z.boolean().optional(),
1358
+ /** Minutes before its start to remind whom it names: up to five, each said once as reminder.due. */
1359
+ reminder_minutes: z.array(z.number().int().min(0).max(527_040)).max(5).optional(),
1020
1360
  })
1021
1361
  .strict()
1022
1362
  .refine(boundedSpan, SPAN_MESSAGE),
@@ -1035,11 +1375,13 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1035
1375
  {
1036
1376
  name: 'slotlock_cancel_reservation',
1037
1377
  title: 'Cancel a reservation',
1038
- description: 'Cancel the reservation with this reference at the next revision (the current one plus 1). Cancelling leaves a tombstone, so a delayed booking cannot bring it back; repeating the cancel answers cancelled: false. Use a new reference for a new reservation.',
1378
+ description: 'Cancel the reservation with this reference at the next revision (the current one plus 1). Cancelling leaves a tombstone, so a delayed booking cannot bring it back; repeating the cancel answers cancelled: false. Use a new reference for a new reservation. Past the resource\'s cancel cutoff it answers 409 booking_rule_cancel_cutoff unless override_reason says why, which the tombstone and the feed keep. A late cancellation\'s fee (the resource\'s cancellation_fee) is answered in cancellation_fee and kept on the feed, for you to charge: Slotlock moves no money.',
1039
1379
  method: 'DELETE',
1040
1380
  path: '/v1/reservations/{reservation_ref}',
1041
- input: z.object({ reservation_ref: IDENTITY, revision: REVISION }).strict(),
1042
- output: z.object({ cancelled: z.boolean(), revision: REVISION }).strict(),
1381
+ input: z
1382
+ .object({ reservation_ref: IDENTITY, revision: REVISION, override_reason: OVERRIDE_REASON.optional() })
1383
+ .strict(),
1384
+ output: z.object({ cancelled: z.boolean(), revision: REVISION, cancellation_fee: RECORDED_FEE.nullable() }).strict(),
1043
1385
  risk: { readOnly: false, destructive: true, idempotent: true },
1044
1386
  invoke: 'cancelReservation',
1045
1387
  example: { reservation_ref: 'booking-BK-42', revision: 2 },
@@ -1056,10 +1398,75 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1056
1398
  invoke: 'checkInReservation',
1057
1399
  example: { reservation_ref: 'booking-BK-42' },
1058
1400
  },
1401
+ {
1402
+ name: 'slotlock_put_contact',
1403
+ title: 'Keep a contact',
1404
+ description: "Keep a person a booking may name to be told about it (contact_ref on slotlock_put_reservation), under your own reference: how to reach them (email, phone for SMS and whatsapp, both E.164) and which channels they agreed to (consent: false is an opt-out a sender must honour). Replaces what was kept under the reference. A sender reads it to send; Slotlock itself sends nothing. created says whether it is new. A tenant keeps at most 100,000 (409 contact_limit_reached).",
1405
+ method: 'PUT',
1406
+ path: '/v1/contacts/{contact_ref}',
1407
+ input: z
1408
+ .object({
1409
+ contact_ref: CONTACT_REF,
1410
+ name: z.string().min(1).max(200).nullable().optional(),
1411
+ email: z.string().min(3).max(320).regex(/^[^\s@]+@[^\s@]+$/, 'must be an email address').nullable().optional(),
1412
+ phone: E164_NUMBER.nullable().optional(),
1413
+ whatsapp: E164_NUMBER.nullable().optional(),
1414
+ consent: CONSENT.optional(),
1415
+ locale: z.string().max(35).regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, 'must be a BCP 47 tag, like en-GB').nullable().optional(),
1416
+ })
1417
+ .strict(),
1418
+ output: z.object({ contact: CONTACT, created: z.boolean() }).strict(),
1419
+ risk: { readOnly: false, destructive: false, idempotent: true },
1420
+ invoke: 'putContact',
1421
+ example: {
1422
+ contact_ref: 'customer-42',
1423
+ name: 'Ada Lovelace',
1424
+ email: 'ada@example.com',
1425
+ phone: '+447700900123',
1426
+ consent: { email: true, sms: false },
1427
+ locale: 'en-GB',
1428
+ },
1429
+ },
1430
+ {
1431
+ name: 'slotlock_get_contact',
1432
+ title: 'Get a contact',
1433
+ description: "The person kept under this reference, for a sender to reach. An unknown one answers 404 contact_not_found.",
1434
+ method: 'GET',
1435
+ path: '/v1/contacts/{contact_ref}',
1436
+ input: z.object({ contact_ref: CONTACT_REF }).strict(),
1437
+ output: z.object({ contact: CONTACT }).strict(),
1438
+ risk: { readOnly: true, destructive: false, idempotent: true },
1439
+ invoke: 'getContact',
1440
+ example: { contact_ref: 'customer-42' },
1441
+ },
1442
+ {
1443
+ name: 'slotlock_list_contacts',
1444
+ title: 'List contacts',
1445
+ description: 'The people the tenant keeps to tell about bookings, by reference, a page at a time: pass next_cursor as cursor for the next page, until it is null.',
1446
+ method: 'GET',
1447
+ path: '/v1/contacts',
1448
+ input: z.object({ limit: PAGE_LIMIT, cursor: CONTACT_REF.optional() }).strict(),
1449
+ output: z.object({ contacts: z.array(CONTACT).max(100), next_cursor: CONTACT_REF.nullable() }).strict(),
1450
+ risk: { readOnly: true, destructive: false, idempotent: true },
1451
+ invoke: 'listContacts',
1452
+ example: { limit: 50 },
1453
+ },
1454
+ {
1455
+ name: 'slotlock_delete_contact',
1456
+ title: 'Forget a contact',
1457
+ description: "Forget a person: their contact, and their reference on every booking that named it, at once (for an erasure request). deleted is false when no contact was kept; bookings counts the bookings that no longer name them. Changes already on the feed keep the reference, never the person.",
1458
+ method: 'DELETE',
1459
+ path: '/v1/contacts/{contact_ref}',
1460
+ input: z.object({ contact_ref: CONTACT_REF }).strict(),
1461
+ output: z.object({ deleted: z.boolean(), bookings: z.number().int().min(0) }).strict(),
1462
+ risk: { readOnly: false, destructive: true, idempotent: true },
1463
+ invoke: 'deleteContact',
1464
+ example: { contact_ref: 'customer-42' },
1465
+ },
1059
1466
  {
1060
1467
  name: 'slotlock_join_waitlist',
1061
1468
  title: 'Join the waitlist',
1062
- description: "Wait for a window on one resource (resource_id) or on any member of a pool (pool) that is taken now. When it comes free (a booking cancelled, a hold released or expired, a no-show), the entries waiting for it are offered in the order they joined: the first gets a hold of it for holder_ref, for offer_ttl_seconds (default 900), and the feed says so (waitlist.offered, with hold_id): confirm the hold to book it. An entry whose window starts first expires (waitlist.expired). A holder may wait for 20 windows at once (409 waitlist_limit_reached); a window is at most 31 days. With an idempotency key (the Idempotency-Key header or idempotency_key), a retry answers the entry the first request made (replayed: true) for as long as it is kept; the same key with another request is 422 idempotency_conflict.",
1469
+ description: "Wait for a window on one resource (resource_id) or on any member of a pool (pool) that is taken now, or with duration_minutes for any slot of that length within it. When time comes free (a booking cancelled, a hold released or expired, a no-show), the entries waiting are offered in the order they joined: the first that fits gets a hold of it (the window, or the earliest slot of its length) for holder_ref, for offer_ttl_seconds (default 900), and the feed says so (waitlist.offered, with hold_id). Confirm the hold to book it (waitlist.accepted), decline it with slotlock_decline_waitlist_offer (waitlist.declined), or let it lapse (waitlist.lapsed); either way the time goes to the next. An entry no slot can be offered to before its window closes expires (waitlist.expired). Joining and leaving are on the feed too (waitlist.joined, waitlist.left). A holder may wait for 20 windows at once (409 waitlist_limit_reached); a window is at most 31 days. With an idempotency key (the Idempotency-Key header or idempotency_key), a retry answers the entry the first request made (replayed: true) for as long as it is kept; the same key with another request is 422 idempotency_conflict.",
1063
1470
  method: 'POST',
1064
1471
  path: '/v1/waitlist',
1065
1472
  input: z
@@ -1070,6 +1477,8 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1070
1477
  end: DATE_TIME,
1071
1478
  holder_ref: z.string().trim().min(1).max(500),
1072
1479
  offer_ttl_seconds: z.number().int().min(60).max(86_400).optional(),
1480
+ /** Any slot of this length within the window (RFC 0004 B6); left out, the whole window. */
1481
+ duration_minutes: z.number().int().min(1).max(44_640).optional(),
1073
1482
  idempotency_key: IDEMPOTENCY_KEY.optional(),
1074
1483
  })
1075
1484
  .strict()
@@ -1117,6 +1526,18 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1117
1526
  invoke: 'leaveWaitlist',
1118
1527
  example: { entry_id: '1b4e28ba-2fa1-41d2-883f-0016d3cca427' },
1119
1528
  },
1529
+ {
1530
+ name: 'slotlock_decline_waitlist_offer',
1531
+ title: 'Decline a waitlist offer',
1532
+ description: 'Turn down the hold a waitlist entry was offered: the hold is let go, the entry is declined (waitlist.declined), and the time is offered to the next who waits. 404 waitlist_offer_not_found when the entry has no open offer.',
1533
+ method: 'POST',
1534
+ path: '/v1/waitlist/{entry_id}/decline',
1535
+ input: z.object({ entry_id: z.string().uuid() }).strict(),
1536
+ output: z.object({ entry: WAITLIST_ENTRY }).strict(),
1537
+ risk: { readOnly: false, destructive: false, idempotent: false },
1538
+ invoke: 'declineWaitlistOffer',
1539
+ example: { entry_id: '1b4e28ba-2fa1-41d2-883f-0016d3cca427' },
1540
+ },
1120
1541
  {
1121
1542
  name: 'slotlock_record_coverage',
1122
1543
  title: 'Record calendar coverage',
@@ -1144,6 +1565,81 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1144
1565
  revision: 7,
1145
1566
  },
1146
1567
  },
1568
+ {
1569
+ name: 'slotlock_put_source_busy',
1570
+ title: "Record a calendar's busy time",
1571
+ description: `For a calendar that gives busy time but not its events (Google freeBusy, Microsoft getSchedule): record its coverage of a window at the source's revision, as slotlock_record_coverage does, and the busy time in it, which replaces what the source said of that window before (what it said outside the window stays). Busy time counts in free/busy and every booking Slotlock makes on the resource is refused over it (409 overlap), as over an event. A confirmed booking the source newly reports busy time over is left as it is and put on the feed once as coverage.conflict, naming the booking and the source by its source_id: someone decides. At most ${SLOTLOCK_SOURCE_BUSY_MAX} intervals; they are clipped to the window and merged. Revisions only go forward; repeating one changes nothing (replayed: true).`,
1572
+ method: 'PUT',
1573
+ path: '/v1/resources/{resource_id}/coverage/{source}/busy',
1574
+ input: z
1575
+ .object({
1576
+ resource_id: RESOURCE_ID,
1577
+ source: SOURCE,
1578
+ start: DATE_TIME,
1579
+ end: DATE_TIME,
1580
+ revision: REVISION,
1581
+ busy: z
1582
+ .array(z
1583
+ .object({ start: DATE_TIME, end: DATE_TIME })
1584
+ .strict()
1585
+ .refine((value) => Date.parse(value.end) > Date.parse(value.start), SPAN_MESSAGE))
1586
+ .max(SLOTLOCK_SOURCE_BUSY_MAX),
1587
+ })
1588
+ .strict()
1589
+ .refine((value) => Date.parse(value.end) > Date.parse(value.start), SPAN_MESSAGE),
1590
+ output: z
1591
+ .object({
1592
+ revision: REVISION,
1593
+ replayed: z.boolean(),
1594
+ busy: z.number().int().min(0),
1595
+ conflicts: z.number().int().min(0),
1596
+ })
1597
+ .strict(),
1598
+ risk: { readOnly: false, destructive: false, idempotent: true },
1599
+ invoke: 'putSourceBusy',
1600
+ example: {
1601
+ resource_id: EXAMPLE_RESOURCE_ID,
1602
+ source: 'google:fleet@example.com',
1603
+ start: '2027-03-01T00:00:00Z',
1604
+ end: '2027-03-15T00:00:00Z',
1605
+ revision: 8,
1606
+ busy: [{ start: '2027-03-02T09:00:00Z', end: '2027-03-02T11:00:00Z' }],
1607
+ },
1608
+ },
1609
+ {
1610
+ name: 'slotlock_report_coverage_conflict',
1611
+ title: 'Report a booking changed in a calendar',
1612
+ description: "For a calendar sync that writes bookings to a calendar: report that one (by reservation_ref or event_id) was changed (changed_in_calendar, with where the calendar now has it) or removed (removed_from_calendar) there. It is put on the change feed as coverage.conflict, named by the source's source_id, with the booking and the calendar's times; the booking is left as it is, for someone to decide. 404 coverage_not_found for a source that has not recorded coverage of the resource; reservation_not_found or event_not_found for a booking not on it.",
1613
+ method: 'POST',
1614
+ path: '/v1/resources/{resource_id}/coverage/{source}/conflicts',
1615
+ input: z
1616
+ .object({
1617
+ resource_id: RESOURCE_ID,
1618
+ source: SOURCE,
1619
+ reservation_ref: IDENTITY.optional(),
1620
+ event_id: z.string().uuid().optional(),
1621
+ reason: z.enum(['changed_in_calendar', 'removed_from_calendar']),
1622
+ calendar_start: DATE_TIME.optional(),
1623
+ calendar_end: DATE_TIME.optional(),
1624
+ })
1625
+ .strict()
1626
+ .refine((value) => (value.reservation_ref === undefined) !== (value.event_id === undefined), {
1627
+ message: 'Name reservation_ref or event_id, not both',
1628
+ })
1629
+ .refine((value) => (value.calendar_start === undefined) === (value.calendar_end === undefined) &&
1630
+ (value.calendar_start === undefined || Date.parse(value.calendar_end) > Date.parse(value.calendar_start)), { message: 'calendar_start and calendar_end come together, the end after the start' }),
1631
+ output: z.object({ reported: z.literal(true) }).strict(),
1632
+ risk: { readOnly: false, destructive: false, idempotent: false },
1633
+ invoke: 'reportCoverageConflict',
1634
+ example: {
1635
+ resource_id: EXAMPLE_RESOURCE_ID,
1636
+ source: 'google:fleet@example.com',
1637
+ reservation_ref: 'booking-BK-42',
1638
+ reason: 'changed_in_calendar',
1639
+ calendar_start: '2027-03-29T10:00:00Z',
1640
+ calendar_end: '2027-03-29T11:00:00Z',
1641
+ },
1642
+ },
1147
1643
  {
1148
1644
  name: 'slotlock_get_coverage',
1149
1645
  title: "A resource's coverage and its health",
@@ -1249,6 +1745,60 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1249
1745
  invoke: 'exportTenantEvent',
1250
1746
  example: { event_id: EXAMPLE_EVENT_ID },
1251
1747
  },
1748
+ {
1749
+ name: 'slotlock_tenant_book_together',
1750
+ title: 'Book resources together, as the tenant',
1751
+ description: "Book the same time on 2-20 resources at once, all or none, as agents' slotlock_book_together does but for the tenant's own systems (RFC 0004 B7): each is the tenant's event, not the key's (no key's quota counts it, and revoking the key leaves it), held to the resources' booking rules; approval policies hold it only when they include the application (include_application). If any is taken, nothing is booked and the refusal names that resource_id. A retry with the same idempotency_key answers the same events. 409 bundles_unavailable where the server books no bundles.",
1752
+ method: 'POST',
1753
+ path: '/v1/tenant/bundles',
1754
+ input: z
1755
+ .object({
1756
+ resource_ids: z.array(RESOURCE_ID).min(2).max(20),
1757
+ starts_at: DATE_TIME,
1758
+ ends_at: DATE_TIME,
1759
+ timezone: TIMEZONE,
1760
+ title: z.string().trim().min(1).max(500).optional(),
1761
+ idempotency_key: IDEMPOTENCY_KEY,
1762
+ })
1763
+ .strict(),
1764
+ output: z.object({ events: z.array(SLOTLOCK_EVENT_OUTPUT).min(2).max(20), replayed: z.boolean() }).strict(),
1765
+ risk: { readOnly: false, destructive: false, idempotent: true },
1766
+ invoke: 'tenantBookTogether',
1767
+ example: {
1768
+ resource_ids: [EXAMPLE_RESOURCE_ID, '1f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b'],
1769
+ starts_at: '2027-03-02T09:00:00Z',
1770
+ ends_at: '2027-03-02T10:00:00Z',
1771
+ timezone: 'Europe/London',
1772
+ idempotency_key: 'desk-bundle-2027-03-02',
1773
+ },
1774
+ },
1775
+ {
1776
+ name: 'slotlock_tenant_book_pool',
1777
+ title: 'Book any member of a pool, as the tenant',
1778
+ description: "Book any one free member of a pool for a time, as agents' slotlock_book_pool does but for the tenant's own systems (RFC 0004 B7), in the pool's order: the event names the member, and is the tenant's, not the key's; approval policies hold it only when they include the application (include_application). 409 pool_full when none is free; 404 pool_not_found. A retry with the same idempotency_key answers the member it booked.",
1779
+ method: 'POST',
1780
+ path: '/v1/tenant/pool-bookings',
1781
+ input: z
1782
+ .object({
1783
+ pool: POOL,
1784
+ starts_at: DATE_TIME,
1785
+ ends_at: DATE_TIME,
1786
+ timezone: TIMEZONE,
1787
+ title: z.string().trim().min(1).max(500).optional(),
1788
+ idempotency_key: IDEMPOTENCY_KEY,
1789
+ })
1790
+ .strict(),
1791
+ output: z.object({ event: SLOTLOCK_EVENT_OUTPUT, replayed: z.boolean() }).strict(),
1792
+ risk: { readOnly: false, destructive: false, idempotent: true },
1793
+ invoke: 'tenantBookPool',
1794
+ example: {
1795
+ pool: 'van',
1796
+ starts_at: '2027-03-02T09:00:00Z',
1797
+ ends_at: '2027-03-02T10:00:00Z',
1798
+ timezone: 'Europe/London',
1799
+ idempotency_key: 'desk-pool-2027-03-02',
1800
+ },
1801
+ },
1252
1802
  {
1253
1803
  name: 'slotlock_cancel_tenant_event',
1254
1804
  title: 'Cancel any event of the tenant',
@@ -1263,6 +1813,8 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1263
1813
  // Required, so clients always send it; the Idempotency-Key header may stand in, since the
1264
1814
  // server copies it in before validation.
1265
1815
  idempotency_key: IDEMPOTENCY_KEY,
1816
+ /** An admin's reason for cancelling a one-off event past its cancel cutoff (RFC 0004 B5). */
1817
+ override_reason: OVERRIDE_REASON.optional(),
1266
1818
  })
1267
1819
  .strict(),
1268
1820
  output: z
@@ -1271,6 +1823,8 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1271
1823
  revision: REVISION,
1272
1824
  cancelled: z.literal(true),
1273
1825
  replayed: z.boolean(),
1826
+ /** A late cancellation's fee, for you to charge (RFC 0004 B5); null when none. */
1827
+ cancellation_fee: RECORDED_FEE.nullable(),
1274
1828
  })
1275
1829
  .strict(),
1276
1830
  risk: { readOnly: false, destructive: true, idempotent: true },
@@ -1331,6 +1885,8 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1331
1885
  no_shows: z.number().int().min(0),
1332
1886
  waitlist_offers: z.number().int().min(0),
1333
1887
  approvals_expired: z.number().int().min(0),
1888
+ bookings_completed: z.number().int().min(0),
1889
+ reminders_due: z.number().int().min(0),
1334
1890
  capped: z.boolean(),
1335
1891
  })
1336
1892
  .strict(),
@@ -1449,7 +2005,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1449
2005
  {
1450
2006
  name: 'slotlock_put_approval_policy',
1451
2007
  title: 'Create or replace an approval policy',
1452
- description: "Which agent bookings (slotlock_create_event, slotlock_confirm_slot, slotlock_book_pool, slotlock_book_together, and moves by slotlock_update_event or slotlock_reschedule) a person approves first, or are refused. applies_to names resources, pools, locations (with the locations in them) and groups; empty, every resource. conditions narrow it: longer_than_minutes, outside_hours (outside the resource's bookable hours), kinds (single, pool, bundle, move); a booking meets the policy when it meets every condition given. outcome require_approval holds the exact time the booking would take, under the approval, and answers the agent 409 approval_pending; refuse answers 403 approval_policy_refused. A person has approval_ttl_minutes to decide (default 1,440; at most 43,200, and the server's ceiling), and must decide lead_minutes before the booking starts: the wait is the earlier of the two. Where several apply, refuse wins, then the one that must be decided soonest. At most 50 policies: one more is 409 approval_policy_limit_reached.",
2008
+ description: "Which agent bookings (slotlock_create_event, slotlock_confirm_slot, slotlock_book_pool, slotlock_book_together, and moves by slotlock_update_event or slotlock_reschedule) a person approves first, or are refused. applies_to names resources, pools, locations (with the locations in them) and groups; empty, every resource. conditions narrow it: longer_than_minutes, outside_hours (outside the resource's bookable hours), kinds (single, pool, bundle, move); a booking meets the policy when it meets every condition given. outcome require_approval holds the exact time the booking would take, under the approval, and answers the agent 409 approval_pending; refuse answers 403 approval_policy_refused. A person has approval_ttl_minutes to decide (default 1,440; at most 43,200, and the server's ceiling), and must decide lead_minutes before the booking starts: the wait is the earlier of the two. Where several apply, refuse wins, then the one that must be decided soonest. The tenant's own bookings through this API (slotlock_tenant_book_together, slotlock_tenant_book_pool) are held only by policies with include_application true, and are the tenant's again when granted. At most 50 policies: one more is 409 approval_policy_limit_reached.",
1453
2009
  method: 'PUT',
1454
2010
  path: '/v1/approval-policies/{policy_ref}',
1455
2011
  input: z
@@ -1461,6 +2017,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1461
2017
  outcome: z.enum(['require_approval', 'refuse']),
1462
2018
  approval_ttl_minutes: z.number().int().min(1).max(SLOTLOCK_MAX_APPROVAL_MINUTES).optional(),
1463
2019
  lead_minutes: z.number().int().min(0).max(SLOTLOCK_MAX_APPROVAL_MINUTES).optional(),
2020
+ include_application: z.boolean().optional(),
1464
2021
  })
1465
2022
  .strict(),
1466
2023
  output: z.object({ policy: APPROVAL_POLICY }).strict(),