@acms-subir/client 0.1.24 → 0.1.26

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/README.md CHANGED
@@ -63,6 +63,7 @@ language. Update the site before shipping an app build with a newer client.
63
63
  | `live()` | Live video: the current broadcast (`snapshot` or `null`), `serverNow` and the owner's `display` choices. Never cached; 404 while `site.features.live` is false |
64
64
  | `announcements({ kind?, pinned?, page?, limit? })`, `announcement(id)`, `paginate.announcements()` | Announcements up right now in the client's language, newest first (`pinned: true`: the banners, urgent first); 404 while `site.features.announcements` is false |
65
65
  | `form(id)`, `submitForm(id, { answers, version?, idempotencyKey? })` | A published form's questions; send answers (checkbox groups as arrays). Each call makes one key; retries reuse it |
66
+ | `requestOptions()`, `submitRequest(input, { idempotencyKey? })` | Event requests without an account: what the form offers (`open`, `types`, `rooms`, `replyTime`, `notice`, `phone`, `timezone`; never cached), and asking for a new event or a change (`{ id }`). 404 while `site.features.requests` is false. See Event requests below |
66
67
  | `manageBooking(token)`, `cancelBooking(token)` | The booker's own booking from its `manageToken`, and cancelling it (or leaving a waitlist, or releasing a held spot); `can` says what is allowed. A waitlisted RSVP has `booking.position` (its place in line), an offered one `booking.holdUntil` |
67
68
  | `confirmBooking(token)` | Take a spot the waitlist offered (`can.confirm`): `confirmed`, or `pending` when RSVPs need approval. 410 once `holdUntil` passed. Core 0.3.40 or later |
68
69
  | `rescheduleAvailability(token, { from?, days?, minutes? })`, `rescheduleBooking(token, { start, minutes? })` | Open times for moving it (its own time counts as free), then the move |
@@ -149,7 +150,7 @@ await pending.catch(() => {});
149
150
  ```
150
151
 
151
152
  Requests that are safe to repeat (GET, PUT, DELETE, device registration, and
152
- POSTs with an Idempotency-Key: bookings and form responses) retry network
153
+ POSTs with an Idempotency-Key: bookings, form responses and event requests) retry network
153
154
  failures, 5xx and 429 responses at most twice (three attempts total), with
154
155
  250/500ms backoff and at least the server's `Retry-After` delay (seconds or HTTP
155
156
  date). A wait that would outlast `timeoutMs` throws the 429 straight away, with
@@ -170,6 +171,52 @@ booking must be retried with its existing key. `AcmsError` exposes `status`,
170
171
  `aborted`, or `invalid_response`; non-JSON HTTP errors use `http_error`.
171
172
  Convenience lookups apply the timeout separately to each underlying request.
172
173
 
174
+ ## Event requests
175
+
176
+ A site can take event requests from people
177
+ without an account (`acms({ requests: true })`, so `site.features.requests`
178
+ is true; staff answer them in Calendar → Requests). Someone asks for
179
+ something new, or for a change to an event, and gives a name, an email and
180
+ optionally a phone. Nothing goes on the calendar until staff approve it.
181
+
182
+ ```ts
183
+ import type { RequestInput } from '@acms-subir/client';
184
+
185
+ const options = await client.requestOptions();
186
+ // { open, types: [{ id, label }], rooms: [{ slug, name, color }], replyTime: '3 days', notice, phone, timezone }
187
+ if (!options.open) {
188
+ // say "We aren't taking requests here right now." and show options.phone; keep the screen itself reachable
189
+ }
190
+ const ask: RequestInput = {
191
+ kind: 'new', type: options.types[0]?.id, title: 'Guitar workshop',
192
+ // the site's wall clock (options.timezone), in the future; the end may be just HH:mm on the same day
193
+ start: '2026-11-07T18:00', end: '20:00',
194
+ repeat: 'weekly', // 'once' (default), 'weekly', or 'other' with repeatText
195
+ rooms: ['fellowship-hall'], // slugs from options.rooms, or roomsUnsure: true
196
+ people: 12, message: 'We bring our own chairs.',
197
+ name: 'Example Guest', email: 'guest@example.com', phone: '555 0100',
198
+ };
199
+ const { id } = await client.submitRequest(ask, { idempotencyKey: crypto.randomUUID() });
200
+
201
+ // a change to a public event: one date of it (YYYY-MM-DD, the site's day), or leave date out for every date
202
+ await client.submitRequest({ kind: 'change', event: 'fall-fair', date: '2026-11-08', message: 'Could it start at 10?', name: 'Example Guest', email: 'guest@example.com' });
203
+ ```
204
+
205
+ - Check whether a room looks free first with `roomFree({ room, start, end })`
206
+ (needs bookings on the site; the times there take a zone, so convert the
207
+ wall-clock times with `options.timezone`).
208
+ - Keep one Idempotency-Key per request and reuse it for retries: a retry
209
+ answers the same `{ id }`, and the same key with other details is a 409
210
+ `changed`. Each call without `idempotencyKey` makes a new one.
211
+ - The client's `locale` (or `input.locale`) goes in the body, so the emails
212
+ about the request speak the app's language.
213
+ - A refusal is an `AcmsError` whose `code` says why: `closed` (409, requests
214
+ closed meanwhile), a field (400: `title`, `type`, `start`, `end`, `past`,
215
+ `far`, `repeat`, `room`, `people`, `event`, `date`, `what` (the change's
216
+ message), `name`, `email`, `phone`), `changed` (409), or `busy` / `full`
217
+ (429, with `retryAfter`). 10 requests a minute per address, plus the
218
+ site's daily cap.
219
+
173
220
  ## Push notifications
174
221
 
175
222
  Available when the site enables push (`acms({ mobile: { push: true } })`);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Site, Theme, Page, Content, Post, ContentList, SearchResults, Links, Form, FormReceipt, FormResponseInput, ManagedBooking, RescheduleInput, ManageAvailabilityQuery, GuestCodeSent, GuestToken, GuestBookings, GuestBookingsQuery, GuestSignedOut, Events, Event, Calendars, Resources, Resource, Availability, BookingInput, BookingResult, RsvpStatus, Device, DeviceInput, DeviceUpdate, DeviceRegistration, DeviceRemoved, StaffLink, PushTopics, LiveStatus, Announcements, AnnouncementDetail, EventDetail, RoomFree } from './types.js';
1
+ import type { Site, Theme, Page, Content, Post, ContentList, SearchResults, Links, Form, FormReceipt, FormResponseInput, ManagedBooking, RescheduleInput, ManageAvailabilityQuery, GuestCodeSent, GuestToken, GuestBookings, GuestBookingsQuery, GuestSignedOut, Events, Event, Calendars, Resources, Resource, Availability, BookingInput, BookingResult, RsvpStatus, Device, DeviceInput, DeviceUpdate, DeviceRegistration, DeviceRemoved, StaffLink, PushTopics, LiveStatus, Announcements, AnnouncementDetail, EventDetail, RoomFree, EventRequestOptions, RequestReceipt } from './types.js';
2
2
  export { AcmsError, isAcmsError } from './errors.js';
3
3
  export type * from './types.js';
4
4
  export * from './editor.js';
@@ -72,6 +72,44 @@ export interface RoomFreeOptions extends RequestOptions {
72
72
  export type BookOptions = BookingInput & RequestOptions & {
73
73
  idempotencyKey?: string;
74
74
  };
75
+ /** Who's asking. `locale`: the language for the emails about it (default: the client's). */
76
+ interface RequestAsker {
77
+ name: string;
78
+ email: string;
79
+ phone?: string;
80
+ locale?: string;
81
+ }
82
+ /** Ask for a new event. Times are the site's wall clock (`requestOptions().timezone`). */
83
+ export interface NewEventRequest extends RequestAsker {
84
+ kind: 'new';
85
+ /** One of `requestOptions().types` (an `id`); required when the site lists any. */
86
+ type?: string;
87
+ title: string;
88
+ /** `YYYY-MM-DDTHH:mm`, in the future. */
89
+ start: string;
90
+ /** `YYYY-MM-DDTHH:mm`, or `HH:mm`: the start's day, or the next when it's before the start (20:00 → 01:00); within a day. */
91
+ end: string;
92
+ /** Default `once`; `other` needs `repeatText` ("every other Tuesday"). */
93
+ repeat?: 'once' | 'weekly' | 'other';
94
+ repeatText?: string;
95
+ /** Room slugs from `requestOptions().rooms`, or `roomsUnsure: true`. */
96
+ rooms?: string[];
97
+ roomsUnsure?: boolean;
98
+ people?: number | null;
99
+ /** Anything else they want staff to know. */
100
+ message?: string;
101
+ }
102
+ /** Ask for a change to a public event. */
103
+ export interface ChangeRequest extends RequestAsker {
104
+ kind: 'change';
105
+ /** The event's slug. */
106
+ event: string;
107
+ /** One of its dates (`YYYY-MM-DD`, the site's day); leave it out for every date. */
108
+ date?: string;
109
+ /** What should change. */
110
+ message: string;
111
+ }
112
+ export type RequestInput = NewEventRequest | ChangeRequest;
75
113
  export interface ApiResponse<T> {
76
114
  version: 1;
77
115
  data: T;
@@ -214,6 +252,14 @@ export declare const endpoints: {
214
252
  readonly method: "GET";
215
253
  readonly path: "/announcements/{id}";
216
254
  };
255
+ readonly requestOptions: {
256
+ readonly method: "GET";
257
+ readonly path: "/requests/options";
258
+ };
259
+ readonly submitRequest: {
260
+ readonly method: "POST";
261
+ readonly path: "/requests";
262
+ };
217
263
  readonly manageBooking: {
218
264
  readonly method: "GET";
219
265
  readonly path: "/bookings/manage";
@@ -335,6 +381,20 @@ export interface AcmsClient {
335
381
  submitForm(id: string, input: FormResponseInput & {
336
382
  idempotencyKey?: string;
337
383
  }, o?: RequestOptions): Promise<FormReceipt>;
384
+ /**
385
+ * What the event request form offers (`site.features.requests`): `open` (false: say so, show `phone`, and keep
386
+ * the screen reachable), `types` and `rooms` to choose from, the site's `replyTime` and `notice` in the client's language,
387
+ * and its `timezone` (the times in a request are its wall clock). Never cached; 404 while the site has requests off.
388
+ */
389
+ requestOptions(o?: RequestOptions): Promise<EventRequestOptions>;
390
+ /**
391
+ * Ask for a new event or a change to one, without an account: `{ id }`. Retries reuse the same Idempotency-Key, so they
392
+ * never send twice; the same key with other details is a 409 `changed`. Refusals carry a `code` (`closed`, `title`,
393
+ * `start`, `email`, `busy`, …). Sends the client's `locale` (or `o.locale`), so the emails about it speak it.
394
+ */
395
+ submitRequest(input: RequestInput, o?: RequestOptions & {
396
+ idempotencyKey?: string;
397
+ }): Promise<RequestReceipt>;
338
398
  /** The booker's own booking, from its private token (`manageToken`). Never cached. Requires `site.features.bookingChanges`. */
339
399
  manageBooking(token: string, o?: RequestOptions): Promise<ManagedBooking>;
340
400
  /** Cancel it; for an RSVP on a waitlist, leave the line or release a held spot. */
package/dist/index.js CHANGED
@@ -25,6 +25,8 @@ export const endpoints = {
25
25
  live: { method: 'GET', path: '/live' },
26
26
  // announcements (404 unless the site turns them on)
27
27
  announcements: { method: 'GET', path: '/announcements' }, announcement: { method: 'GET', path: '/announcements/{id}' },
28
+ // event requests without an account (404 unless the site turns them on): what the form offers (never cached), and sending one
29
+ requestOptions: { method: 'GET', path: '/requests/options' }, submitRequest: { method: 'POST', path: '/requests' },
28
30
  // the booker's own booking: its private token goes in the Booking-Token header
29
31
  manageBooking: { method: 'GET', path: '/bookings/manage' }, cancelBooking: { method: 'POST', path: '/bookings/manage/cancel' },
30
32
  // take a spot an RSVP's waitlist offered
@@ -36,8 +38,9 @@ export const endpoints = {
36
38
  guestBookings: { method: 'GET', path: '/guest/bookings' }, guestSignOut: { method: 'POST', path: '/guest/revoke' },
37
39
  };
38
40
  // site: its legal links; pushTopics: topic names; form: its questions;
39
- // submitForm: the receipt's thank-you (any site ignores it on that POST)
40
- const localized = new Set(['home', 'page', 'content', 'contentById', 'search', 'events', 'rsvpStatus', 'site', 'pushTopics', 'announcements', 'announcement', 'eventDetail', 'form', 'submitForm']);
41
+ // submitForm: the receipt's thank-you (any site ignores it on that POST);
42
+ // requestOptions: the kinds and the site's words
43
+ const localized = new Set(['home', 'page', 'content', 'contentById', 'search', 'events', 'rsvpStatus', 'site', 'pushTopics', 'announcements', 'announcement', 'eventDetail', 'form', 'submitForm', 'requestOptions']);
41
44
  // localized later (site, pushTopics: core 0.3.32; form: with form
42
45
  // translations); older sites answer ?locale= with a 400, so ask them again without it
43
46
  const lateLocalized = new Set(['site', 'pushTopics', 'form']);
@@ -122,8 +125,8 @@ export function createClient(options) {
122
125
  opts.signal?.addEventListener('abort', abort, { once: true });
123
126
  const timer = setTimeout(() => controller.abort(new AcmsError(0, 'timeout', 'Request timed out.', undefined, undefined, key)), timeoutMs);
124
127
  const signal = controller.signal;
125
- // availability, live status and RSVP seats are never served from the cache
126
- const cacheable = endpoint.method === 'GET' && name !== 'availability' && name !== 'live' && name !== 'rsvpStatus' && name !== 'roomFree' && !extra.headers && cache;
128
+ // availability, live status, RSVP seats, free rooms and whether requests are open are never served from the cache
129
+ const cacheable = endpoint.method === 'GET' && name !== 'availability' && name !== 'live' && name !== 'rsvpStatus' && name !== 'roomFree' && name !== 'requestOptions' && !extra.headers && cache;
127
130
  // only replay what the server treats as repeatable: a lost reply to a code
128
131
  // exchange or a move must not run it twice
129
132
  const replayable = endpoint.method !== 'POST' || !!key || name === 'registerDevice';
@@ -331,6 +334,13 @@ export function createClient(options) {
331
334
  const { idempotencyKey, ...body } = input;
332
335
  return request('submitForm', o, {}, id, body, idempotencyKey ?? uuid());
333
336
  },
337
+ requestOptions: (o = {}) => request('requestOptions', o),
338
+ submitRequest: (input, o = {}) => {
339
+ const { idempotencyKey, signal } = o;
340
+ // the language rides in the body: the emails about it speak it
341
+ const locale = input.locale ?? o.locale ?? options.locale;
342
+ return request('submitRequest', { signal }, {}, undefined, locale === undefined ? input : { ...input, locale }, idempotencyKey ?? uuid());
343
+ },
334
344
  manageBooking: (token, o = {}) => request('manageBooking', o, {}, undefined, undefined, undefined, bookingAuth(token)),
335
345
  cancelBooking: (token, o = {}) => request('cancelBooking', o, {}, undefined, undefined, undefined, bookingAuth(token)),
336
346
  confirmBooking: (token, o = {}) => request('confirmBooking', o, {}, undefined, undefined, undefined, bookingAuth(token)),
package/dist/types.d.ts CHANGED
@@ -37,6 +37,7 @@ export type Site = {
37
37
  "links": boolean;
38
38
  "live": boolean;
39
39
  "announcements": boolean;
40
+ "requests": boolean;
40
41
  };
41
42
  "legal": {
42
43
  "privacy": string | null;
@@ -198,6 +199,30 @@ export type OccurrenceMoved = ({
198
199
  "url": string;
199
200
  }) | (null);
200
201
  }) | (null);
202
+ export type OccurrencePlace = ({
203
+ "name": string;
204
+ "address": string;
205
+ "url": string;
206
+ }) | (null);
207
+ export type EventSegment = {
208
+ "from": string;
209
+ "start": string;
210
+ "end": string;
211
+ "rrule": string;
212
+ "repeats": string;
213
+ "cancelled": boolean;
214
+ "note": string;
215
+ "location": ({
216
+ "name": string;
217
+ "address": string;
218
+ "url": string;
219
+ }) | (null);
220
+ "rooms": Array<{
221
+ "slug": string;
222
+ "name": string;
223
+ "color": string | null;
224
+ }>;
225
+ };
201
226
  export type Event = {
202
227
  "id": string;
203
228
  "slug": string;
@@ -299,6 +324,25 @@ export type Event = {
299
324
  "note": string;
300
325
  };
301
326
  };
327
+ "segments": Array<{
328
+ "from": string;
329
+ "start": string;
330
+ "end": string;
331
+ "rrule": string;
332
+ "repeats": string;
333
+ "cancelled": boolean;
334
+ "note": string;
335
+ "location": ({
336
+ "name": string;
337
+ "address": string;
338
+ "url": string;
339
+ }) | (null);
340
+ "rooms": Array<{
341
+ "slug": string;
342
+ "name": string;
343
+ "color": string | null;
344
+ }>;
345
+ }>;
302
346
  "notify": {
303
347
  "changes": Array<string>;
304
348
  };
@@ -331,6 +375,11 @@ export type Events = {
331
375
  "url": string;
332
376
  }) | (null);
333
377
  }) | (null);
378
+ "location": ({
379
+ "name": string;
380
+ "address": string;
381
+ "url": string;
382
+ }) | (null);
334
383
  "rooms": Array<{
335
384
  "slug": string;
336
385
  "name": string;
@@ -437,6 +486,25 @@ export type Events = {
437
486
  "note": string;
438
487
  };
439
488
  };
489
+ "segments": Array<{
490
+ "from": string;
491
+ "start": string;
492
+ "end": string;
493
+ "rrule": string;
494
+ "repeats": string;
495
+ "cancelled": boolean;
496
+ "note": string;
497
+ "location": ({
498
+ "name": string;
499
+ "address": string;
500
+ "url": string;
501
+ }) | (null);
502
+ "rooms": Array<{
503
+ "slug": string;
504
+ "name": string;
505
+ "color": string | null;
506
+ }>;
507
+ }>;
440
508
  "notify": {
441
509
  "changes": Array<string>;
442
510
  };
@@ -463,6 +531,11 @@ export type EventDetail = {
463
531
  "url": string;
464
532
  }) | (null);
465
533
  }) | (null);
534
+ "location": ({
535
+ "name": string;
536
+ "address": string;
537
+ "url": string;
538
+ }) | (null);
466
539
  "rooms": Array<{
467
540
  "slug": string;
468
541
  "name": string;
@@ -570,6 +643,25 @@ export type EventDetail = {
570
643
  "note": string;
571
644
  };
572
645
  };
646
+ "segments": Array<{
647
+ "from": string;
648
+ "start": string;
649
+ "end": string;
650
+ "rrule": string;
651
+ "repeats": string;
652
+ "cancelled": boolean;
653
+ "note": string;
654
+ "location": ({
655
+ "name": string;
656
+ "address": string;
657
+ "url": string;
658
+ }) | (null);
659
+ "rooms": Array<{
660
+ "slug": string;
661
+ "name": string;
662
+ "color": string | null;
663
+ }>;
664
+ }>;
573
665
  "notify": {
574
666
  "changes": Array<string>;
575
667
  };
@@ -649,6 +741,11 @@ export type SearchResults = {
649
741
  "url": string;
650
742
  }) | (null);
651
743
  }) | (null);
744
+ "location": ({
745
+ "name": string;
746
+ "address": string;
747
+ "url": string;
748
+ }) | (null);
652
749
  "rooms": Array<{
653
750
  "slug": string;
654
751
  "name": string;
@@ -756,6 +853,25 @@ export type SearchResults = {
756
853
  "note": string;
757
854
  };
758
855
  };
856
+ "segments": Array<{
857
+ "from": string;
858
+ "start": string;
859
+ "end": string;
860
+ "rrule": string;
861
+ "repeats": string;
862
+ "cancelled": boolean;
863
+ "note": string;
864
+ "location": ({
865
+ "name": string;
866
+ "address": string;
867
+ "url": string;
868
+ }) | (null);
869
+ "rooms": Array<{
870
+ "slug": string;
871
+ "name": string;
872
+ "color": string | null;
873
+ }>;
874
+ }>;
759
875
  "notify": {
760
876
  "changes": Array<string>;
761
877
  };
@@ -1292,10 +1408,34 @@ export type LiveStatus = {
1292
1408
  "dock": boolean;
1293
1409
  };
1294
1410
  };
1411
+ export type EventRequestOptions = {
1412
+ "open": boolean;
1413
+ "types": Array<{
1414
+ "id": string;
1415
+ "label": string;
1416
+ }>;
1417
+ "rooms": Array<{
1418
+ "slug": string;
1419
+ "name": string;
1420
+ "color": string;
1421
+ }>;
1422
+ "replyTime": string;
1423
+ "notice": string;
1424
+ "phone": string;
1425
+ "timezone": string;
1426
+ "events": Array<{
1427
+ "slug": string;
1428
+ "title": string;
1429
+ "dates": Array<string>;
1430
+ }>;
1431
+ };
1432
+ export type RequestReceipt = {
1433
+ "id": string;
1434
+ };
1295
1435
  export type ApiErrorResponse = {
1296
1436
  "version": 1;
1297
1437
  "error": {
1298
- "code": "invalid_request" | "unauthorized" | "forbidden" | "not_found" | "method_not_allowed" | "conflict" | "duplicate" | "gone" | "payload_too_large" | "unsupported_media_type" | "rate_limited" | "upstream_error" | "internal_error";
1438
+ "code": "invalid_request" | "unauthorized" | "forbidden" | "not_found" | "method_not_allowed" | "conflict" | "duplicate" | "gone" | "payload_too_large" | "unsupported_media_type" | "rate_limited" | "upstream_error" | "internal_error" | "closed" | "trap" | "busy" | "full" | "changed" | "title" | "type" | "start" | "end" | "past" | "far" | "repeat" | "room" | "people" | "event" | "date" | "what" | "name" | "email" | "phone";
1299
1439
  "message": string;
1300
1440
  "issues"?: Array<{
1301
1441
  "path": Array<string | number>;
@@ -2864,6 +3004,26 @@ export type FormResponseInput = {
2864
3004
  "version"?: string;
2865
3005
  "website"?: string;
2866
3006
  };
3007
+ export type RequestFields = {
3008
+ "kind": "new" | "change";
3009
+ "type"?: string;
3010
+ "title"?: string;
3011
+ "start"?: string;
3012
+ "end"?: string;
3013
+ "repeat"?: "once" | "weekly" | "other";
3014
+ "repeatText"?: string;
3015
+ "rooms"?: Array<string>;
3016
+ "roomsUnsure"?: boolean;
3017
+ "people"?: (number) | (null);
3018
+ "event"?: string;
3019
+ "date"?: string;
3020
+ "message"?: string;
3021
+ "name": string;
3022
+ "email": string;
3023
+ "phone"?: string;
3024
+ "locale"?: string;
3025
+ "website"?: string;
3026
+ };
2867
3027
  export type RescheduleInput = {
2868
3028
  "start": string;
2869
3029
  "minutes"?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/client",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Typed, dependency-free client for the ACMS public mobile API.",
5
5
  "type": "module",
6
6
  "license": "MIT",