@acms-subir/core 0.3.55 → 0.3.57

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 (73) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +16 -4
  3. package/docs/calendar.md +189 -2
  4. package/docs/mobile.md +3 -1
  5. package/docs/server.md +84 -1
  6. package/integration.mjs +34 -0
  7. package/mobile-template/client/AGENTS.md +1 -0
  8. package/mobile-template/client/README.md +1 -1
  9. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  10. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  11. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  12. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  13. package/mobile-template/recipes/event-request/recipe.json +21 -0
  14. package/package.json +1 -1
  15. package/src/actions/index.ts +52 -9
  16. package/src/components/EventPage.astro +6 -5
  17. package/src/components/MobileApp.tsx +2 -2
  18. package/src/components/calendar/CalendarBoard.tsx +6 -3
  19. package/src/components/editor/event/SingleDates.tsx +87 -17
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/api.ts +23 -2
  22. package/src/lib/calendar/board.ts +3 -2
  23. package/src/lib/calendar/cards.ts +4 -4
  24. package/src/lib/calendar/change-migration.ts +17 -3
  25. package/src/lib/calendar/data.ts +9 -2
  26. package/src/lib/calendar/event-words.ts +8 -0
  27. package/src/lib/calendar/events.ts +128 -15
  28. package/src/lib/calendar/google/map.ts +1 -1
  29. package/src/lib/calendar/host-requests.ts +65 -10
  30. package/src/lib/calendar/ics-write.ts +65 -15
  31. package/src/lib/calendar/member-words.ts +14 -1
  32. package/src/lib/calendar/moves.ts +15 -9
  33. package/src/lib/calendar/request-admin.ts +212 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +42 -8
  41. package/src/lib/calendar/segments.ts +522 -0
  42. package/src/lib/editor/schedule.ts +2 -2
  43. package/src/lib/email/service.ts +6 -1
  44. package/src/lib/legal/app-privacy.ts +2 -2
  45. package/src/lib/legal/facts.ts +4 -0
  46. package/src/lib/legal/text-en.ts +3 -0
  47. package/src/lib/legal/text-es.ts +3 -0
  48. package/src/lib/mobile/calendar.ts +1 -0
  49. package/src/lib/mobile/http.ts +2 -1
  50. package/src/lib/mobile/openapi.ts +8 -0
  51. package/src/lib/mobile/requests.ts +27 -0
  52. package/src/lib/mobile/schemas.ts +24 -6
  53. package/src/lib/mobile/site.ts +3 -0
  54. package/src/lib/push/service.ts +42 -10
  55. package/src/lib/push/types.ts +3 -1
  56. package/src/lib/repo/migrations.ts +5 -2
  57. package/src/lib/repo/sql.ts +24 -8
  58. package/src/lib/repo/types.ts +3 -1
  59. package/src/lib/scheduler.ts +3 -0
  60. package/src/lib/schema.ts +67 -2
  61. package/src/lib/sections.ts +1 -1
  62. package/src/lib/sync/catalog.ts +1 -0
  63. package/src/pages/admin/calendar/events/index.astro +11 -8
  64. package/src/pages/admin/calendar/requests.astro +298 -0
  65. package/src/pages/api/calendar/events/index.ts +2 -1
  66. package/src/pages/api/v1/requests/index.ts +6 -0
  67. package/src/pages/api/v1/requests/options.ts +7 -0
  68. package/src/pages/members/events/index.astro +84 -6
  69. package/src/pages/request/index.astro +182 -0
  70. package/src/server/index.ts +102 -4
  71. package/src/styles/editor.css +2 -0
  72. package/src/styles/site.css +29 -0
  73. package/src/virtual.d.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.57
4
+
5
+ ### Patch Changes
6
+
7
+ - b9eed5e: Change a repeating event from one of its dates on, and keep the dates before it as they were.
8
+
9
+ - **One event.** When a class moves for good, from Thursdays 6 PM to Wednesdays 5 PM, the dates before the change stay exactly as they were, and it stays one event: the same page, slug, hosts, RSVPs and My events entry. Each change is a segment on the events row (`CalEvent.segments`, migration 70), with its own first date, repeat, rooms and place.
10
+ - **Where.** Change it from the event editor's Single dates (**From here on…**, with **Undo**), or drag a date on the Schedule and choose **This and following dates**. Hosts can ask with **Change from this date on…** on My events, and staff apply it from Requests.
11
+ - **What follows.** Sign-ups move to the new date nearest them within half a week, and each person is emailed the new time with your note. Cancelled, skipped and moved dates follow the same way. Sign-ups with no new date near them are cancelled, with the email. `npx acms docs calendar` has the rule.
12
+ - **Every output.** `/calendar`, the event page ("Weekly on Thursday · From Wed, Jan 9: Weekly on Wednesday"), cards, the TV display, room holds and the app API show the new dates. `calendar.ics` writes one VEVENT per part, as Google does for "this and following". The "When an event changes" rule sends one notice: "From Wed, Jan 9: Weekly on Wednesday, 5:00 PM".
13
+ - **API.** Events carry `segments` (`from`, `start`, `end`, `rrule`, `repeats`, `cancelled`, `note`, `location`, `rooms`). `repeats` is the rule in effect now, and each occurrence has its own `location`. Push data gains `change: 'fromDate' | 'cancelledFrom'`.
14
+ - **Server API.** `@acms-subir/core/server` adds `changeFromDate`, `cancelFromDate`, `restoreFromDate`, `revertFromDate` (staff and `GOOGLE_SYNC`, all idempotent, `quiet` for a sync) and `requestFromDate` for hosts.
15
+
16
+ ## 0.3.56
17
+
18
+ ### Patch Changes
19
+
20
+ - 191830f: Anyone can now ask for an event, or for a change to one, without an account. Turn it on with `acms({ requests: true })`; it needs the calendar.
21
+
22
+ - **On the website**, a form at `/request` (and `/es/request` and so on), in the visitor's language and themed through the site's tokens. It asks what they're planning, the kind, date and times, how it repeats, rooms (or "Not sure"), how many people, and anything else. "Change to an event" asks which event, optionally which date, and what should change. A live line says whether the chosen rooms look free then (free or busy only). `requests: { path: '/ask' }` moves it; `requests: { page: false }` drops it but keeps the rest, and a site file at the path replaces it.
23
+ - **In apps**, `GET /api/v1/requests/options` (open or closed, the kinds and requestable rooms, the reply time, notice and office phone, in the asked language) and `POST /api/v1/requests` (Idempotency-Key; returns `{ id }`; refusals carry a code such as `title`, `past`, `room`, `closed` or `busy`). `site.features.requests` says the site has it. 10 requests a minute and 20 a day per address, 5 a day per email address, plus the site's daily cap.
24
+ - **Calendar → Requests** lists requests from the app, the website and hosts on My events (new events, suggested changes and date moves, each answered there) in one place, with Pending, Approved and Declined tabs and, on each new request, what takes its rooms then (12 weeks for weekly ones). **Approve** makes a draft that's already filled in: title, times, weekly when asked, the kind's tag and calendar, and the rooms held while it's a draft. The person's details stay on the request, never on the event. Approving a change opens the event, or that date's Move… form. **Reply** and **Decline** (with an optional reason) email the person in the language they asked in. **Delete as spam** removes one without an email. The list shows 50 at a time. Reading the list needs "Create and edit content"; answering needs "Publish content".
25
+ - **Settings** on that page (needs "Manage rooms and calendars"): open or closed (closed forms and apps say so and show the office phone, and never disappear), the kinds with their label in each language, tag and calendar, the reply time and opening notice in each language, the office phone, who hears about new requests (else `members.notify`, else the owners), an optional push to their linked phones, the daily cap (100) and how long requests are kept (180 days after the answer, or after they arrive if nobody answers). Rooms marked **Anyone can check if it's free** are the ones people can choose.
26
+ - **Emails:** the person gets "We got your request" in their language. It never repeats what they typed, so the form can't be used to send someone else a message, and each address gets at most 3 a day. Staff get every detail, with Reply-To set to the person, and only while the request still waits.
27
+ - **Privacy:** the generated privacy notice gets an "Event requests" section (what's kept, why, and when it's deleted), and the app store answers list event requests under contact info and user content. Requests are in backups.
28
+ - **Server API** (`@acms-subir/core/server`): `requestOptions`, `submitRequest` (for a site's own form), `listRequests`, `approveRequest`, `declineRequest` and `replyToRequest`. See `npx acms docs calendar` and `npx acms docs server`.
29
+
30
+ Requests use the `event_requests` table that came with hosts' date moves (migration 68); this release adds no migration. After updating, a site that wants requests adds `requests: true`, marks the rooms people may ask for as **Anyone can check if it's free**, and sets the kinds, reply time and office phone in Calendar → Requests → Settings. Update the privacy notice and the app's store answers, which now include requests.
31
+
3
32
  ## 0.3.55
4
33
 
5
34
  ### Patch Changes
@@ -283,8 +283,8 @@ var V65_COLUMNS = [
283
283
  "start_at",
284
284
  "end_at"
285
285
  ];
286
- var EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, "moved_dates"];
287
- var EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, "moved_dates"];
286
+ var EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, "moved_dates", "segments"];
287
+ var EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, "moved_dates", "segments"];
288
288
  var snapshotOf = (cols, row) => `json_object(${cols.map((c) => `'${c}',${row}.${c}`).join(",")})`;
289
289
  var live = "(SELECT suppressed FROM webhook_control WHERE id=1)=0";
290
290
  var quiet = "COALESCE((SELECT quiet FROM push_control WHERE id=1),0)";
@@ -331,6 +331,15 @@ var MOVED_DATES_MIGRATION = (contentVersionUpdate) => ({ version: 66, statements
331
331
  `DROP TRIGGER event_changes_delete`,
332
332
  ...eventChangeTriggers([...V65_SNAPSHOT, "moved_dates"], [...V65_COLUMNS, "moved_dates"])
333
333
  ] });
334
+ var SEGMENTS_MIGRATION = (contentVersionUpdate) => ({ version: 70, statements: [
335
+ `ALTER TABLE events ADD COLUMN segments TEXT NOT NULL DEFAULT '[]'`,
336
+ `DROP TRIGGER content_version_events_update`,
337
+ contentVersionUpdate,
338
+ `DROP TRIGGER event_changes_insert`,
339
+ `DROP TRIGGER event_changes_update`,
340
+ `DROP TRIGGER event_changes_delete`,
341
+ ...eventChangeTriggers([...V65_SNAPSHOT, "moved_dates", "segments"], [...V65_COLUMNS, "moved_dates", "segments"])
342
+ ] });
334
343
 
335
344
  // src/lib/maps/migration.ts
336
345
  var PLACES_MIGRATION = { version: 43, statements: [
@@ -1351,12 +1360,15 @@ var MIGRATIONS = [
1351
1360
  { version: 68, statements: REQUEST_TABLE },
1352
1361
  // two-way Google Calendar sync: the account, links, pairs, outgoing queue
1353
1362
  // (triggers on events) and Recent changes. Not public, so no cache trigger.
1354
- GCAL_MIGRATION
1363
+ GCAL_MIGRATION,
1364
+ // a series changed from one date on (CalEvent.segments): a column on the
1365
+ // row, so the triggers on events see it, like moved_dates
1366
+ SEGMENTS_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"])[2])
1355
1367
  ];
1356
1368
  var CURRENT_CONTENT_VERSION_COLUMNS = {
1357
1369
  ...CONTENT_VERSION_COLUMNS,
1358
1370
  settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
1359
- events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"],
1371
+ events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"],
1360
1372
  resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"],
1361
1373
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
1362
1374
  };
package/docs/calendar.md CHANGED
@@ -1,6 +1,7 @@
1
- # Calendar: repeats, single dates, rooms and clashes
1
+ # Calendar: repeats, single dates, rooms, clashes and requests
2
2
 
3
- How events repeat, how one date of a series is changed, and how events hold rooms.
3
+ How events repeat, how one date of a series is changed, how a series changes from a
4
+ date on, how events hold rooms, and how people ask for events.
4
5
  Read it with `npx acms docs calendar`. The functions named here come from
5
6
  `@acms-subir/core/server` (`npx acms docs server` has their signatures), and the app
6
7
  API is in `docs/mobile-api-v1.md` in the framework repository.
@@ -147,6 +148,120 @@ Errors carry a `status`, a message safe to show people, and a `code`:
147
148
  | 403 | `room-not-allowed` | a host picking a room the event doesn't use and nobody may request |
148
149
  | 401, 404 | | who is acting |
149
150
 
151
+ ## From this date on
152
+
153
+ When a class moves for good, say from Thursdays 6 PM to Wednesdays 5 PM, change the
154
+ series **from one of its dates on**. The dates before it stay exactly as they were. It
155
+ stays one event: the same page, slug, hosts, RSVPs and My events entry.
156
+
157
+ Ways to do it:
158
+
159
+ - the event editor's **Single dates** list: **From here on…** on a date (the new first
160
+ date, its times, how it repeats, its rooms and a note);
161
+ - dragging a date of a repeating event on the Schedule, then **This and following
162
+ dates**. **Undo** puts it back;
163
+ - a host's **Change from this date on…** on My events, which is a request staff apply
164
+ from Schedule → List → Requests;
165
+ - `changeFromDate` from site code or a calendar sync.
166
+
167
+ The new first date may come before the date you change from (from Thursday Jan 10 to
168
+ Wednesday Jan 9), but it has to come after the date before it. Leave the repeat as it
169
+ is and it carries on from the new day: "weekly on Thursday" moved to a Wednesday becomes
170
+ "weekly on Wednesday", a COUNT becomes what's left of it, and an end date stays.
171
+
172
+ ### What follows the change
173
+
174
+ Each date from the change point on is matched to the new date nearest it, within half
175
+ a week (3½ days). Each new date takes at most one old date, closest pairs first. Then:
176
+
177
+ - its RSVPs move to that date, and each person gets the "new time" email in their own
178
+ language, with your note. Places in line move quietly;
179
+ - a cancelled date stays cancelled on its match, and a skipped one stays skipped;
180
+ - a moved date keeps its new time, as its match's date;
181
+ - a date with no match has its RSVPs cancelled (with the "cancelled" email). Its
182
+ cancellation, skip or move is dropped, and `changeFromDate` lists it in `dropped`.
183
+
184
+ So Thursday → Wednesday moves each week's sign-ups to that week's Wednesday, and twice
185
+ a week → once a week keeps the day that matches and cancels the other.
186
+
187
+ Changing again from a later date replaces every change after it, the way Google's
188
+ "this and following" works. Changing from the first date of an earlier change replaces
189
+ that change. A change that gives exactly the dates the rule before it gives is no
190
+ change at all: it's removed. **Undo** in the editor (`revertFromDate`) removes a change
191
+ the same way, with RSVPs following back.
192
+
193
+ ### How it shows
194
+
195
+ - `/calendar`, the event page, cards, the TV display, the app API and room holds show
196
+ the new dates like any other date of the series. Its rooms and place hold from then.
197
+ - The event page's repeat line says the rule in effect and the change still to come:
198
+ "Weekly on Thursday · From Wed, Jan 9: Weekly on Wednesday". Once it has taken over,
199
+ just "Weekly on Wednesday".
200
+ - `calendar.ics` has one VEVENT per part, the way Google writes "this and following":
201
+ the event's own rule keeps its UID and stops with an UNTIL at the end of the day
202
+ before the change,
203
+ and each change is a recurring event of its own with UID `<id>-<from>@<host>` (`from`
204
+ as `YYYYMMDDTHHmm`), with its own cancelled and moved dates. Calendar apps that
205
+ subscribed show both parts.
206
+ - The "When an event changes" rule sends one notice: "Music class · From Wed, Jan 9:
207
+ Weekly on Wednesday, 5:00 PM" (`data.change: 'fromDate'`), or "Cancelled: every date
208
+ from …" (`'cancelledFrom'`).
209
+ - `/api/v1/events` and `/api/calendar/events` carry `segments: [{ from, start, end,
210
+ rrule, repeats, cancelled, note, location, rooms }]` on each event, and `repeats` is
211
+ the rule in effect now. Each occurrence has `location`: its own place (a moved
212
+ date's, or its segment's), `null` for the event's.
213
+
214
+ ### Stored on the event row
215
+
216
+ Changes are `CalEvent.segments`, sorted by `from`, on the events row (so its triggers,
217
+ the event_changes outbox and the content version see them):
218
+
219
+ `{ from, start, end, rrule, roomIds?, location?, cancelled, note, by, at }`
220
+
221
+ - `from` is an occurrence key of the rule before it: that rule's dates from that day on
222
+ are gone (so retiming the earlier rule never leaves a second date that day);
223
+ - `start`/`end` are the new part's first date, wall-clock like the event's own;
224
+ - `rrule` is its rule (`''` = just that date);
225
+ - `roomIds`/`location` are its rooms and place (unset = the event's);
226
+ - `cancelled: true` cancels every date of it.
227
+
228
+ Its dates have their own keys, so `cancelledDates`, `movedDates` and `exdates` work on
229
+ them, and so do `moveOccurrence` and `cancelOccurrence`. `seriesParts(event)` splits a
230
+ series into its parts. Dragging the whole series on the Schedule moves every part.
231
+
232
+ Why one event with segments, and not a second event for the later part: hosts, RSVPs,
233
+ the page and slug, My events and the Classes card all stay with the one event, every
234
+ event write path already goes through the row's triggers, and calendar feeds can still
235
+ show the parts as separate series.
236
+
237
+ ### Server API
238
+
239
+ ```ts
240
+ changeFromDate(repo, who, eventId, fromKey, { start, end?, rrule?, roomIds?, location?, note? }, ctx, { quiet? })
241
+ → { event, clashes, changed, rsvps: { moved, cancelled }, dropped }
242
+ cancelFromDate(repo, who, eventId, fromKey, ctx, { quiet? }) // every date from it on, struck through
243
+ restoreFromDate(repo, who, eventId, fromKey, ctx, { quiet? }) // undo that; RSVPs stay cancelled
244
+ revertFromDate(repo, who, eventId, from, ctx, { quiet? }) // remove a change: the rule before it carries on
245
+ ```
246
+
247
+ `who` is staff ("Create and edit content") or `GOOGLE_SYNC`. Room clashes at the new
248
+ times (180 days ahead) come back in `clashes` as warnings. Hosts get 403
249
+ `host-request`: they send `requestFromDate` instead (`npx acms docs server`). All are
250
+ idempotent (`changed: false`). `quiet` skips the member push; the RSVP emails still go.
251
+ A `fromKey` that a change took over from (no longer a date itself) means that change,
252
+ so a sync can retry safely: the same change again is `changed: false`, whoever sends it
253
+ and whenever. A change whose first date has started can't be replaced or undone
254
+ (`started`), and neither can a cancelled part be restored once its dates have started.
255
+
256
+ | status | code | when |
257
+ |---|---|---|
258
+ | 400 | `one-off`, `not-a-date`, `started`, `past`, `bad-time` | as for moves |
259
+ | 400 | `first-date` | changing from the series' very first date (edit the event instead) |
260
+ | 400 | `previous-date` | the new first date isn't after the date before the change |
261
+ | 400 | `bad-rule` | a rule the calendar can't read, or a first date that isn't one of its dates |
262
+ | 403 | `host-request` | a host: send it as a request |
263
+ | 409 | `cancelled-event` | the whole event is cancelled |
264
+
150
265
  ## Rooms
151
266
 
152
267
  Rooms, people and equipment are **Resources** (Admin → Calendar → Rooms; they need
@@ -315,3 +430,75 @@ calendars in step, both ways, within a few minutes. The full guide is
315
430
  until "from this date on" exists.
316
431
  - Site code can read an event's Google copies with `googleCopies(repo, eventId)`, and
317
432
  Recent changes with `googleSyncHistory(repo)`.
433
+
434
+ ## Requests without an account
435
+
436
+ `acms({ requests: true })` lets anyone ask for a new event, or for a change to one,
437
+ without an account:
438
+
439
+ - from the website's form at `/request` (and `/es/request` and so on);
440
+ - from an app (`GET /api/v1/requests/options`, `POST /api/v1/requests`);
441
+ - from the site's own form, through `submitRequest`.
442
+
443
+ Staff answer them on **Calendar → Requests**. That one list also shows hosts'
444
+ requests from My events. `requests: { path: '/ask' }` moves the form, and
445
+ `requests: { page: false }` drops it but keeps the API and the admin. A site file at
446
+ the form's path replaces it.
447
+
448
+ Each request is a row of its own (`RequestRecord`), not an event:
449
+
450
+ - `kind`:
451
+ - `'new'`: `data` has `type`, `title`, `start`, `end`, `repeat` (`once`, `weekly`
452
+ or `other` with `repeatText`), `roomIds`, `roomsUnsure` and `people`;
453
+ - `'change'`: `eventId`, plus `key` for one date (`''` = every date).
454
+ - `state`: `pending`, `approved`, `declined` or `withdrawn`.
455
+ - `source`: `app`, `web` or `member`.
456
+ - `requester`: `{ name, email, phone, locale }`, for a person without an account.
457
+ - `note`: their own words to staff.
458
+ - `reply`: staff's last answer.
459
+
460
+ Hosts' date moves (C3/C4) use the same table with kinds `move` and `from-date`.
461
+
462
+ **Settings** (Calendar → Requests → Settings, in `settings.calendar.requests`):
463
+
464
+ - **Open or closed.** Closed forms and apps say so and show the office phone; they never
465
+ disappear.
466
+ - **Kinds.** Each has a label per language, plus the tag and calendar an approved
467
+ draft gets.
468
+ - **Reply time and notice**, per language.
469
+ - **Office phone.**
470
+ - **Who hears about new requests.** Their email, else `members.notify`, else the owners.
471
+ Optionally a push to their linked phones, without names.
472
+ - **A daily cap** on new requests (100), on top of 10 a minute and 20 a day per
473
+ address and 5 a day per email address.
474
+ - **How long requests are kept** (180 days) after their answer, or after they
475
+ arrived if nobody answered. The scheduler deletes them then.
476
+
477
+ **Rooms** people can choose are the ones marked **Anyone can check if it’s free**
478
+ (`requestable`). The form's live "free then" line uses `GET /api/v1/rooms/free`, which
479
+ says free or busy, never what or who.
480
+
481
+ **Answering** (needs **Publish content**; reading the list needs **Create and edit
482
+ content**):
483
+
484
+ - **Approve** a new request: makes a draft that's already filled in. It has the
485
+ title and times, `FREQ=WEEKLY` when asked, the kind's tag and calendar (else the
486
+ default calendar), and the rooms held while it's a draft. The draft opens in the
487
+ editor. The person's details stay on the request, never on the event.
488
+ - **Approve** a change: opens the event, or that date's Move… form
489
+ (`/admin/calendar/events/<id>?move=<key>`).
490
+ - Both email the person, with an optional message, unless you untick it.
491
+ - **Decline**, with an optional reason, and **Reply** email the person in the
492
+ language they asked in. A reply leaves the request as it is. **Delete as spam**
493
+ removes a request from the app or the website without emailing anyone.
494
+ - Each row shows what takes its rooms then. Weekly requests are checked for 12
495
+ weeks.
496
+
497
+ **Emails.** The person gets "We got your request" in their language. It never
498
+ repeats what they typed (the title or message), so the form can't be used to send
499
+ someone else a message, and each address gets at most 3 a day. Staff get every
500
+ detail in the site's main language, with Reply-To set to the person.
501
+
502
+ **Privacy.** The generated privacy notice gets an "Event requests" section, and
503
+ the store answers list event requests under contact info and user content.
504
+ Requests are in backups.
package/docs/mobile.md CHANGED
@@ -114,7 +114,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
114
114
 
115
115
  | Hook | For |
116
116
  | --- | --- |
117
- | `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `rsvp`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`) |
117
+ | `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `rsvp`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`, `announcements`, `requests`) |
118
118
  | `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
119
119
  | `usePosts({ collection, limit, q })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`); `q` gives ranked search matches |
120
120
  | `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
@@ -128,6 +128,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
128
128
  | `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
129
129
  | `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
130
130
  | `useRoomFree({ room, start, end })` | bookings: is a requestable room free (no names) |
131
+ | `useRequestOptions()` / `useSubmitRequest()` | event requests without an account (`features.requests`): the form's kinds, rooms, words and office phone (`open: false` = say so and show the phone, never hide the screen), and sending one with its own Idempotency-Key; `problem` and `field` say what to fix |
131
132
  | `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
132
133
  | `usePushRegistration({ topics })` / `usePushTopics()` / `useNewPushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications); topic names come in the app's language |
133
134
  | `useAnnouncements({ kind, pinned })` / `useAnnouncement(id)` / `useAnnouncementState()` | announcements up right now; unread and closed banners kept on the phone (a dot, never a number); idle while `features.announcements` is false |
@@ -150,6 +151,7 @@ npx acms mobile recipe add news # posts list, article
150
151
  npx acms mobile recipe add booking # resources → slots → form → confirmation (expo-crypto)
151
152
  npx acms mobile recipe add push # notifications: turn on, topic toggles, taps open the page (expo-notifications, expo-device)
152
153
  npx acms mobile recipe add announcements # list with kind tabs and unread dots, detail, home banners (site needs announcements on)
154
+ npx acms mobile recipe add event-request # "Request an event" screen, a More row and an events-list link, no account (site needs requests on; expo-crypto, expo-secure-store)
153
155
  ```
154
156
 
155
157
  Each prints the `npx expo install …` line for its packages: run it (doctor
package/docs/server.md CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
19
19
  moveHostedDate, moveHostedDateBack, requestDateMove, listMyDateRequests, withdrawDateRequest,
20
20
  approveDateRequest, declineDateRequest,
21
+ changeFromDate, cancelFromDate, restoreFromDate, revertFromDate, requestFromDate,
21
22
  } from '@acms-subir/core/server';
22
23
  ```
23
24
 
@@ -38,6 +39,10 @@ cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteC
38
39
  restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
39
40
  moveOccurrence(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, input: MoveInput, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<MoveResult>
40
41
  moveOccurrenceBack(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<MoveResult>
42
+ changeFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, fromKey: string, input: ChangeFromInput, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
43
+ cancelFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, fromKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
44
+ restoreFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, fromKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
45
+ revertFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, from: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
41
46
  eventRoomClashes(repo: Repo, event: string | CalEvent, opts?: { horizonDays?: number; now?: Date | number }): Promise<EventClash[]>
42
47
  roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<BusySlot[]>
43
48
 
@@ -61,11 +66,22 @@ setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurre
61
66
  moveHostedDate(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, input: MoveInput, ctx: WriteContext): Promise<HostMoveResult>
62
67
  moveHostedDateBack(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<HostMoveResult>
63
68
  requestDateMove(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, input: MoveInput | null, message: string, ctx: WriteContext): Promise<RequestRecord>
69
+ requestFromDate(repo: Repo, who: ServerUser | null, eventId: string, fromKey: string, input: Omit<ChangeFromInput, 'location'>, message: string, ctx: WriteContext): Promise<RequestRecord>
64
70
  listMyDateRequests(repo: Repo, who: ServerUser | null, eventId?: string): Promise<RequestRecord[]>
65
71
  withdrawDateRequest(repo: Repo, who: ServerUser | null, requestId: string): Promise<void>
66
- approveDateRequest(repo: Repo, who: ServerUser | null, requestId: string, ctx: WriteContext, seen?: number): Promise<{ request: RequestRecord; moved: MoveResult }>
72
+ approveDateRequest(repo: Repo, who: ServerUser | null, requestId: string, ctx: WriteContext, seen?: number): Promise<{ request: RequestRecord; moved?: MoveResult; changed?: ChangeFromResult }>
67
73
  declineDateRequest(repo: Repo, who: ServerUser | null, requestId: string, reply: string, ctx: WriteContext, seen?: number): Promise<RequestRecord>
68
74
 
75
+ // requests without an account (acms({ requests })): asking needs no account; listing needs
76
+ // content.edit, answering content.publish
77
+ requestOptions(repo: Repo, locale?: string): Promise<{ open: boolean; types: { id: string; label: string }[]; rooms: { slug: string; name: string; color: string }[]; replyTime: string; notice: string; phone: string; timezone: string; events: { slug: string; title: string; dates: string[] }[] }>
78
+ submitRequest(repo: Repo, input: RequestInput, opts: { key: string; address: string; context: WriteContext }): Promise<{ id: string; created: boolean }>
79
+ listRequests(repo: Repo, who: ServerUser | null, q?: { states?: RequestState[]; kinds?: RequestKind[]; sources?: RequestSource[]; eventId?: string; userId?: string; order?: 'oldest' | 'newest' | 'decided'; limit?: number; offset?: number }): Promise<RequestRecord[]>
80
+ approveRequest(repo: Repo, who: ServerUser | null, id: string, ctx: WriteContext, seen?: number, opts?: { message?: string; email?: boolean }): Promise<{ request: RequestRecord; event: CalEvent | null; next: string }>
81
+ declineRequest(repo: Repo, who: ServerUser | null, id: string, reason: string, ctx: WriteContext, seen?: number, opts?: { email?: boolean }): Promise<RequestRecord>
82
+ deleteRequest(repo: Repo, who: ServerUser | null, id: string): Promise<void>
83
+ replyToRequest(repo: Repo, who: ServerUser | null, id: string, message: string, ctx: WriteContext): Promise<RequestRecord>
84
+
69
85
  type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
70
86
  interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
71
87
  interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean; roomIds: string[]; moved?: { from: { start: number; end: number }; note: string; location?: EventLocation } }
@@ -74,6 +90,9 @@ interface MoveResult { event: CalEvent; clashes: BusySlot[]; changed: boolean }
74
90
  type HostMoveResult = { moved: MoveResult } | { request: RequestRecord }
75
91
  interface RequestRecord { id: string; kind: 'new' | 'change' | 'move' | 'from-date'; state: 'pending' | 'approved' | 'declined' | 'withdrawn'; source: 'member' | 'web' | 'app'; userId: string; requester: { name: string; email: string; phone: string; locale: string } | null; eventId: string; key: string; data: Record<string, unknown>; note: string; reply: string; reviewedBy: string; reviewedAt: number; createdAt: number; updatedAt: number }
76
92
  interface MoveRequestData { start: string; end: string; roomIds?: string[]; note: string; back?: boolean }
93
+ interface FromDateRequestData { start: string; end: string; rrule: string; roomIds?: string[]; note: string }
94
+ interface ChangeFromInput { start: string; end?: string; rrule?: string; roomIds?: string[]; location?: Partial<EventLocation> | null; note?: string }
95
+ interface ChangeFromResult { event: CalEvent; clashes: BusySlot[]; changed: boolean; rsvps: { moved: number; cancelled: number }; dropped: string[] }
77
96
  interface Hold { resourceId: string; eventId: string; key: string; title: string; start: number; end: number; eventStart: number; eventEnd: number }
78
97
  interface EventClash { hold: Hold; other: Hold }
79
98
  interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
@@ -177,6 +196,29 @@ full table is in `npx acms docs calendar`).
177
196
  `npx acms docs calendar` has the details: what follows a moved date, how it shows,
178
197
  and the error statuses.
179
198
 
199
+ ## From this date on
200
+
201
+ - **`changeFromDate(repo, who, eventId, fromKey, input, ctx, opts?)`**: changes a repeating
202
+ event from one of its dates (`fromKey`, an occurrence key) on. `input.start` is the new
203
+ first date, wall-clock in the event's timezone. It may come before `fromKey` but has to
204
+ come after the date before it. `end` defaults to the dates' length, `rrule` to the rule
205
+ in effect on the new weekday (`''` = just that date), and `roomIds` / `location` to
206
+ what's in effect there. Dates before it stay exactly as they were. RSVPs, cancelled,
207
+ skipped and moved dates follow to the new date nearest them within half a week. RSVPs
208
+ with no new date are cancelled, and `dropped` lists the other dates that had none.
209
+ `clashes` lists what else holds the new rooms over the next 180 days.
210
+ - **`cancelFromDate(...)`** / **`restoreFromDate(...)`**: cancel every date from one on
211
+ (they stay listed, struck through) and undo that.
212
+ - **`revertFromDate(repo, who, eventId, from, ctx)`**: removes the change that took over
213
+ at `from`.
214
+
215
+ All four check `who`: staff ("Create and edit content") or `GOOGLE_SYNC`. A host gets 403
216
+ with code `host-request` and sends **`requestFromDate`** instead (rooms among the event's
217
+ own and the requestable ones). Staff answer it with `approveDateRequest` /
218
+ `declineDateRequest`. All of these are idempotent (`changed: false`), and a `fromKey` that a
219
+ change took over from means that change. The changes are stored on the event as
220
+ `segments`. `npx acms docs calendar` has the rules and the error codes.
221
+
180
222
  ## Rooms
181
223
 
182
224
  Events hold rooms (`event.roomHold.resourceIds`, the Rooms box in the event editor)
@@ -460,3 +502,44 @@ if (Astro.request.method === 'POST') {
460
502
 
461
503
  `/classes/mine` would list `listMyEvents(repo, me)` and show `e.request?.state`
462
504
  (`'pending'`, `'declined'` with `e.request.reply`, or no request once approved).
505
+
506
+ ## Requests without an account
507
+
508
+ With `acms({ requests })`, anyone can ask for an event or a change to one (see `npx
509
+ acms docs calendar`). The built-in form at `/request` and `POST /api/v1/requests` use
510
+ these functions, and so can a site's own form:
511
+
512
+ - **`requestOptions(repo, locale)`**: whether requests are open, the kinds, requestable
513
+ rooms and the events a change can be about (with their next dates), and the site's
514
+ reply time, notice and phone, in that language.
515
+ - **`submitRequest(repo, input, { key, address, context })`**: `input` is the same as
516
+ the API body (`kind: 'new'` or `'change'`, `name`, `email`…). `key` is a random UUID v4
517
+ your page keeps for this form, so a double submit stores one request. `address` is
518
+ `Astro.clientAddress`, for the 10-a-minute limit. Refusals throw with a `status` and a
519
+ `code` (`RequestRefusal`: `title`, `start`, `past`, `room`, `email`, `closed`,
520
+ `busy`…). Show your own words for each code.
521
+ - **`listRequests(repo, who, q)`**: for staff (`content.edit`). `states: ['pending']`
522
+ comes oldest first.
523
+ - **`approveRequest`**, **`declineRequest`** and **`replyToRequest`** answer one (`content.publish`) and
524
+ email the person in their language (approve and decline take `email: false` to stay quiet).
525
+ **`deleteRequest`** removes spam from the app or the website without an email. Pass `seen` (the `updatedAt` you showed) and an
526
+ edited request answers 409 instead.
527
+
528
+ ```astro
529
+ ---
530
+ // src/pages/ask.astro: the site's own request form
531
+ import { getRepo, submitRequest } from '@acms-subir/core/server';
532
+ const repo = await getRepo(Astro.locals);
533
+ let done = false, problem = '';
534
+ if (Astro.request.method === 'POST') {
535
+ const f = await Astro.request.formData();
536
+ try {
537
+ await submitRequest(repo, {
538
+ kind: 'new', title: String(f.get('title')), start: `${f.get('date')}T${f.get('from')}`, end: String(f.get('to')),
539
+ name: String(f.get('name')), email: String(f.get('email')),
540
+ }, { key: String(f.get('key')), address: Astro.clientAddress, context: { url: Astro.url, locals: Astro.locals } });
541
+ done = true;
542
+ } catch (e) { problem = (e as { code?: string }).code ?? 'failed'; }
543
+ }
544
+ ---
545
+ ```
package/integration.mjs CHANGED
@@ -448,6 +448,20 @@ function resolveMembers(members, calendar) {
448
448
  // the built-in pages are "My events": they need the calendar
449
449
  return { enabled: calendar.enabled, base, notify: notify.map((e) => e.trim().toLowerCase()), hostMoves };
450
450
  }
451
+ function resolveRequests(requests, calendar, reserved) {
452
+ if (requests === undefined || requests === false) return { enabled: false, path: '/request', page: true };
453
+ if (requests === true) requests = {};
454
+ if (!requests || typeof requests !== 'object' || Array.isArray(requests)) throw new Error('acms: requests must be true, false or an object');
455
+ unknownKeys('requests', requests, ['path', 'page']);
456
+ const path = requests.path ?? '/request';
457
+ if (typeof path !== 'string' || !/^\/[a-z0-9-]+(\/[a-z0-9-]+)*$/.test(path)) {
458
+ throw new Error(`acms: requests.path must start with "/" and use lowercase letters, digits, and hyphens (no trailing slash) — got "${path}"`);
459
+ }
460
+ if (RESERVED_BASES.has(path) || reserved.includes(path)) throw new Error(`acms: requests.path "${path}" collides with another route — pick another path`);
461
+ if (requests.page !== undefined && typeof requests.page !== 'boolean') throw new Error('acms: requests.page must be true or false');
462
+ // requests become calendar events: they need the calendar
463
+ return { enabled: calendar.enabled, path, page: requests.page ?? true };
464
+ }
451
465
  function resolveAnnouncements(announcements, reserved) {
452
466
  if (announcements === undefined || announcements === false) return { enabled: false, base: '/announcements', pages: true, topics: {} };
453
467
  if (announcements === true) announcements = {};
@@ -713,6 +727,14 @@ function acmsVitePlugin(userConfig) {
713
727
  * `topics` picks the push topic each kind starts with in the composer, e.g.
714
728
  * `{ registration: 'registrations', help: 'help' }` (otherwise a topic named
715
729
  * after the kind, such as `events` or `help`, else everyone).
730
+ * @param {boolean | {path?: string, page?: boolean}} [options.requests]
731
+ * Event requests without an account (off by default; needs the calendar):
732
+ * anyone asks for a new event or a change to one from the app
733
+ * (GET /api/v1/requests/options, POST /api/v1/requests) or the website's
734
+ * form (default path '/request', also under each language's prefix). Staff
735
+ * answer them on Calendar → Requests, where approving makes a filled-in
736
+ * draft event. `page: false` keeps the API and the admin but drops the
737
+ * built-in form, so a site can build its own on the same server API.
716
738
  * @param {string[]} [options.signedRoutes]
717
739
  * The site's own routes that receive posts from another server and check
718
740
  * its signature themselves (an SMS or payment provider's webhook), as route
@@ -741,6 +763,7 @@ export default function acms(options = {}) {
741
763
  if (clash) throw new Error(`acms: members.base "${members.base}" collides with another configured path — pick another`);
742
764
  }
743
765
  const announcements = resolveAnnouncements(options.announcements, [...contentBases.keys(), ...(calendar.enabled ? [calendar.base, calendar.eventsBase, calendar.bookBase] : []), ...(members.enabled ? [members.base] : [])]);
766
+ const requests = resolveRequests(options.requests, calendar, [...contentBases.keys(), ...(calendar.enabled ? [calendar.base, calendar.eventsBase, calendar.bookBase] : []), ...(members.enabled ? [members.base] : []), ...(announcements.enabled ? [announcements.base] : [])]);
744
767
  const roles = resolveRoles(options.roles, members);
745
768
  const permissions = resolvePermissions(options.permissions, roles);
746
769
  const maps = resolveMaps(options.maps);
@@ -771,6 +794,7 @@ export default function acms(options = {}) {
771
794
  permissions,
772
795
  members,
773
796
  announcements,
797
+ requests,
774
798
  };
775
799
  const routes = [...ROUTES,
776
800
  ['/api/v1/site', 'api/v1/site.ts'],
@@ -942,6 +966,16 @@ export default function acms(options = {}) {
942
966
  );
943
967
  if (announcements.pages) routes.push([announcements.base, 'announcements/index.astro'], [`${announcements.base}/[id]`, 'announcements/[id].astro']);
944
968
  }
969
+ // event requests from people without an account (acms({ requests })): the
970
+ // app API, Calendar → Requests and, unless page: false, the public form
971
+ if (requests.enabled) {
972
+ routes.push(
973
+ ['/admin/calendar/requests', 'admin/calendar/requests.astro'],
974
+ ['/api/v1/requests', 'api/v1/requests/index.ts'],
975
+ ['/api/v1/requests/options', 'api/v1/requests/options.ts'],
976
+ );
977
+ if (requests.page) routes.push([requests.path, 'request/index.astro']);
978
+ }
945
979
  // "My events" for signed-in members (acms({ members })): thin pages over
946
980
  // lib/calendar/requests.ts. A site file at any of these paths replaces that
947
981
  // page; `members: false` drops them all (members.enabled needs the calendar).
@@ -78,6 +78,7 @@ npx acms mobile recipe add news # posts list and article
78
78
  npx acms mobile recipe add booking # resources → slots → form → confirmation
79
79
  npx acms mobile recipe add push # notifications: turn on, topics, taps (site needs push on)
80
80
  npx acms mobile recipe add announcements # list, detail, home banners (site needs announcements on)
81
+ npx acms mobile recipe add event-request # Request an event: screen, More row, events-list link (site needs requests on)
81
82
  ```
82
83
 
83
84
  Each recipe prints the extra Expo packages to install (`npx expo install ...`).
@@ -28,7 +28,7 @@ site) and `.env.local` (written by `acms mobile dev`, not committed).
28
28
  ## Add screens
29
29
 
30
30
  ```sh
31
- npx acms mobile recipe add events # also: news, booking, push, rsvp, announcements
31
+ npx acms mobile recipe add events # also: news, booking, push, rsvp, announcements, event-request
32
32
  ```
33
33
 
34
34
  Build your own with the hooks from `@acms-subir/native`. `AGENTS.md` has the