@acms-subir/client 0.1.24 → 0.1.25

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;
@@ -1292,10 +1293,34 @@ export type LiveStatus = {
1292
1293
  "dock": boolean;
1293
1294
  };
1294
1295
  };
1296
+ export type EventRequestOptions = {
1297
+ "open": boolean;
1298
+ "types": Array<{
1299
+ "id": string;
1300
+ "label": string;
1301
+ }>;
1302
+ "rooms": Array<{
1303
+ "slug": string;
1304
+ "name": string;
1305
+ "color": string;
1306
+ }>;
1307
+ "replyTime": string;
1308
+ "notice": string;
1309
+ "phone": string;
1310
+ "timezone": string;
1311
+ "events": Array<{
1312
+ "slug": string;
1313
+ "title": string;
1314
+ "dates": Array<string>;
1315
+ }>;
1316
+ };
1317
+ export type RequestReceipt = {
1318
+ "id": string;
1319
+ };
1295
1320
  export type ApiErrorResponse = {
1296
1321
  "version": 1;
1297
1322
  "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";
1323
+ "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
1324
  "message": string;
1300
1325
  "issues"?: Array<{
1301
1326
  "path": Array<string | number>;
@@ -2864,6 +2889,26 @@ export type FormResponseInput = {
2864
2889
  "version"?: string;
2865
2890
  "website"?: string;
2866
2891
  };
2892
+ export type RequestFields = {
2893
+ "kind": "new" | "change";
2894
+ "type"?: string;
2895
+ "title"?: string;
2896
+ "start"?: string;
2897
+ "end"?: string;
2898
+ "repeat"?: "once" | "weekly" | "other";
2899
+ "repeatText"?: string;
2900
+ "rooms"?: Array<string>;
2901
+ "roomsUnsure"?: boolean;
2902
+ "people"?: (number) | (null);
2903
+ "event"?: string;
2904
+ "date"?: string;
2905
+ "message"?: string;
2906
+ "name": string;
2907
+ "email": string;
2908
+ "phone"?: string;
2909
+ "locale"?: string;
2910
+ "website"?: string;
2911
+ };
2867
2912
  export type RescheduleInput = {
2868
2913
  "start": string;
2869
2914
  "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.25",
4
4
  "description": "Typed, dependency-free client for the ACMS public mobile API.",
5
5
  "type": "module",
6
6
  "license": "MIT",