@acms-subir/core 0.3.47 → 0.3.49

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 (48) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/cli/migrations.generated.mjs +42 -23
  3. package/docs/calendar.md +80 -0
  4. package/docs/server.md +28 -3
  5. package/docs/theme-authoring.md +1 -1
  6. package/integration.mjs +37 -0
  7. package/package.json +1 -1
  8. package/src/actions/index.ts +76 -11
  9. package/src/components/EventPage.astro +18 -12
  10. package/src/components/calendar/CalendarBoard.tsx +43 -19
  11. package/src/components/editor/event/EventSheet.tsx +2 -0
  12. package/src/components/editor/event/SingleDates.tsx +123 -0
  13. package/src/lib/booking/holds.ts +7 -7
  14. package/src/lib/calendar/api.ts +11 -1
  15. package/src/lib/calendar/board.ts +7 -3
  16. package/src/lib/calendar/cards.ts +5 -5
  17. package/src/lib/calendar/change-migration.ts +40 -15
  18. package/src/lib/calendar/data.ts +9 -3
  19. package/src/lib/calendar/event-words.ts +9 -0
  20. package/src/lib/calendar/events.ts +94 -9
  21. package/src/lib/calendar/ics-write.ts +25 -2
  22. package/src/lib/calendar/moves.ts +291 -0
  23. package/src/lib/calendar/render.ts +10 -6
  24. package/src/lib/calendar/schedule.ts +73 -61
  25. package/src/lib/email/booking-words.ts +7 -1
  26. package/src/lib/email/service.ts +6 -1
  27. package/src/lib/email/templates.ts +6 -2
  28. package/src/lib/mobile/calendar.ts +5 -2
  29. package/src/lib/mobile/schemas.ts +7 -3
  30. package/src/lib/origin-check.ts +54 -0
  31. package/src/lib/push/service.ts +44 -10
  32. package/src/lib/push/types.ts +2 -0
  33. package/src/lib/repo/migrations.ts +5 -2
  34. package/src/lib/repo/sql.ts +28 -10
  35. package/src/lib/repo/types.ts +6 -3
  36. package/src/lib/schema.ts +21 -0
  37. package/src/lib/sections.ts +5 -2
  38. package/src/lib/sync/catalog.ts +1 -0
  39. package/src/middleware.ts +5 -0
  40. package/src/pages/api/calendar/events/index.ts +6 -0
  41. package/src/pages/members/_Layout.astro +1 -0
  42. package/src/pages/members/events/index.astro +1 -1
  43. package/src/server/index.ts +33 -0
  44. package/src/styles/editor.css +19 -0
  45. package/src/styles/schedule.css +2 -0
  46. package/src/styles/site.css +6 -0
  47. package/src/theme-sdk/calendar.ts +3 -2
  48. package/src/virtual.d.ts +2 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.49
4
+
5
+ ### Patch Changes
6
+
7
+ - a25078e: Amazon SES delivery webhooks and the apps now reach the site. Astro's cross-site form check (`security.checkOrigin`, on by default) refused them on every built site with `403 Cross-site POST form submissions are forbidden`, before ACMS saw the request. It doesn't run in `astro dev`, which is why nobody noticed:
8
+
9
+ - **SES:** Amazon SNS posts to `/api/email/webhooks/ses` as `text/plain` without an Origin header. SES deliveries, bounces and complaints were never recorded, bounced addresses weren't suppressed, and the SNS subscription could never be confirmed. Resend and Mailgun send JSON and weren't affected.
10
+ - **Apps:** apps send no Origin either, so every app request with no body, or a form body, was refused. With the published `@acms-subir/client`, that's turning notifications off (`unregisterDevice`, so the phone stayed registered and kept getting pushes), unlinking a staff phone, cancelling or confirming a booking, and guest sign-out. Editor apps couldn't sign in, refresh or sign out, upload media, or delete a resource, booking link, time block or calendar.
11
+
12
+ ACMS now runs the check itself, first thing in its middleware, and turns Astro's copy off. Anything a page on another site can post with a visitor's cookies gets exactly Astro's answer, as before. That covers a POST whose Origin isn't the site's, or that has none, when its body is a form, `text/plain` or missing, to admin pages, Actions, public forms, member pages and the APIs. A unit test compares the two rules on every combination of method, Origin, Content-Type, headers and route that such a page can send. What goes through now:
13
+
14
+ - requests a browser only sends to another site after asking it first (PUT, PATCH and DELETE, and requests with `Authorization: Bearer …` or `Booking-Token`). ACMS's CORS answers never let cookies ride along. Basic auth doesn't count, since browsers add it to form posts on their own.
15
+ - `/api/email/webhooks/[provider]`, which checks the provider's signature.
16
+ - the editor apps' OAuth form posts to `/api/v1/auth/token` and `/api/v1/auth/revoke`, which carry their own code or token and never use cookies.
17
+
18
+ The check now also runs in `astro dev`, so local work and the e2e suites see what a built site does. A site that set `security: { checkOrigin: false }` in `astro.config` keeps it off everywhere. If a dev server behind a proxy starts refusing your own admin forms, the proxy's Host doesn't match the browser's Origin.
19
+
20
+ Sites with their own signature-checked webhooks (an SMS or payment provider that posts form data) can list those route patterns in `acms({ signedRoutes: ['/api/sms/[event]'] })`. Admin pages, Astro's own routes and ACMS's routes can't be listed.
21
+
22
+ After updating, nothing needs to change, and apps work again without an update. On a site that uses SES, check the SNS topic's subscription to `https://<site>/api/email/webhooks/ses`. If it isn't "Confirmed", choose **Request confirmation**, or create it again if SNS has removed it. The site now confirms it.
23
+
24
+ ## 0.3.48
25
+
26
+ ### Patch Changes
27
+
28
+ - 9505051: Moved dates: one date of a repeating event can move to another day, time or room and stay that date, keeping its key, its sign-ups, its hosts and its page. Moving one date used to split it off into a copy.
29
+
30
+ **Where staff move a date:**
31
+
32
+ - the event editor's new **Single dates** list: the next dates, each with **Move…** (date, times, rooms for that date and a public note), **Cancel date**, and **Move back** or **Restore**. These apply at once, even on a published event, and leave unpublished edits alone;
33
+ - the Schedule: dragging one date of a series stores a moved date, and in the week view a drag moves by time as well as day (in 15-minute steps, whole series too). **Undo** reverses it, and a moved date's pop-up shows "Moved here from Thu, Jan 10 · 6:00 PM" with **Move back**. When another event, booking or block holds the room at the new time, the toast says so.
34
+
35
+ **What follows a moved date:**
36
+
37
+ - It comes out at its new time everywhere with its original key and `moved: { from: { start, end }, note }`. It's labelled **Moved** with the old time and the note on the event page ("Moved from Thu, Jan 10 · 6:00 PM."), on `/calendar` and event cards, and on the hosts' My events. The words are in English, Spanish, French, German and Portuguese.
38
+ - `?on=` finds it by its new day or the day it moved from.
39
+ - `calendar.ics` and the event's `.ics` get an override (`RECURRENCE-ID` of the old date with the new `DTSTART`), so calendar apps move that one date.
40
+ - Its RSVPs move with it. Each person with a seat gets a "This date moved" email in their own language, with the note and the button to view or cancel their spot; places in line move quietly. Reminders and "starting soon" go at the new time.
41
+ - Its room hold moves to the new time, and to the date's own rooms when it moved into others.
42
+ - The "When an event changes" push rule announces it, keeping the 10-minute merge: "Music class · Moved to Wed, Jan 9, 5:00 PM (was Thu, Jan 10, 6:00 PM)". Moving it back is announced too, and several moved dates are listed by day.
43
+
44
+ A date is moved or cancelled, never both. Cancelling a moved date cancels it at its new time and drops the move; a cancelled date must be restored before it can move. Moving the whole series keeps a moved date at its new time, under its new key.
45
+
46
+ **API:**
47
+
48
+ - `/api/v1/events`, `/api/v1/events/:slug`, `/api/v1/search` and `/api/calendar/events` occurrences carry `moved` (`null` when not moved), and `/api/calendar/events` occurrences also carry their `key`.
49
+ - Events carry the public `movedDates` (`{ start, end, note }` by original key). Staff callers (an `events:read` API key, the agent tools) also see the date's rooms, place and who moved it. Moves fire the `event.updated` webhook.
50
+ - New in `@acms-subir/core/server`:
51
+ - `moveOccurrence(repo, who, eventId, key, { start, end?, roomIds?, location?, note? }, ctx, { quiet? })` and `moveOccurrenceBack(...)`. Both check who is acting: staff ("Create and edit content") and the new `GOOGLE_SYNC` actor (a calendar sync acting with staff rights) may move any date, and get a room taken at the new time back as `clashes`, a warning. A host of the event may move its dates only while the rooms are free (409 otherwise), and staff (`members.notify`, else the owners) are emailed "Date moved".
52
+ - `quiet: true` skips the push and that staff email, for a sync catching up; the RSVP emails still go.
53
+ - Moving a date to where it already is returns `changed: false` and writes, sends and emails nothing, so a sync can retry safely. Cancelling an already cancelled date, or restoring one that isn't cancelled, is likewise a no-op, now even after the date has passed.
54
+ - Refusals carry a `code` beside the `status`: `one-off`, `not-a-date`, `started`, `past`, `original-passed`, `bad-time`, `cancelled-event`, `cancelled-date`, `room-taken`. The table is in `npx acms docs calendar`.
55
+ - Occurrences carry `moved`, calendar views carry `key` and `moved`, and holds follow a moved date's own rooms.
56
+
57
+ Migration 66 adds `events.moved_dates`, a column on the events row, so row-level triggers on `events` see moves. It recreates the `event_changes` triggers (from 0.3.46) with it, so a move or move-back sends `event.updated` and the change push through that queue, and a quiet move is recorded as quiet. Moves are included in backups and environment sync. `npx acms docs calendar` has a Moved dates section, and `npx acms docs server` covers the two new functions.
58
+
3
59
  ## 0.3.47
4
60
 
5
61
  ### Patch Changes
@@ -84,24 +84,24 @@ function trigger(name, action, table, when, event, data) {
84
84
  function content(kind) {
85
85
  const table = `${kind}s`;
86
86
  const fields = kind === "post" ? ["collection", "slug", "title", "status", "body", "body_json", "body_html", "excerpt", "tags", "cover", "cover_alt", "seo_title", "seo_desc", "featured", "author_id", "template", "published_at"] : ["slug", "title", "status", "is_home", "locale", "blocks", "body_json", "body_html", "seo_title", "seo_desc", "template"];
87
- const snapshot2 = (row) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
87
+ const snapshot = (row) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
88
88
  ${kind === "post" ? `'collection',${row}.collection` : `'locale',${row}.locale,'isHome',json(CASE WHEN ${row}.is_home THEN 'true' ELSE 'false' END)`})`;
89
- const changed2 = fields.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(" OR ");
89
+ const changed = fields.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(" OR ");
90
90
  const event = `CASE WHEN OLD.status!='published' THEN '${kind}.published'
91
91
  WHEN NEW.status!='published' THEN '${kind}.unpublished' ELSE '${kind}.updated' END`;
92
92
  return [
93
- trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot2("NEW")})`),
93
+ trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot("NEW")})`),
94
94
  trigger(
95
95
  `webhook_${kind}_update`,
96
96
  "UPDATE",
97
97
  table,
98
- `(OLD.status='published' OR NEW.status='published') AND (${changed2})`,
98
+ `(OLD.status='published' OR NEW.status='published') AND (${changed})`,
99
99
  event,
100
- `json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${snapshot2("NEW")}
101
- ELSE json_set(${snapshot2("OLD")},'$.status',NEW.status) END),
102
- 'previous',json(CASE WHEN OLD.status='published' THEN ${snapshot2("OLD")} ELSE 'null' END))`
100
+ `json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${snapshot("NEW")}
101
+ ELSE json_set(${snapshot("OLD")},'$.status',NEW.status) END),
102
+ 'previous',json(CASE WHEN OLD.status='published' THEN ${snapshot("OLD")} ELSE 'null' END))`
103
103
  ),
104
- trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot2("OLD")})`)
104
+ trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot("OLD")})`)
105
105
  ];
106
106
  }
107
107
  var media = (row) => `json_object('media',json_object('id',${row}.id,'key',${row}.key,'filename',${row}.filename,
@@ -220,7 +220,7 @@ var ROOM_HOLD_MIGRATION = { version: 41, statements: [
220
220
 
221
221
  // src/lib/calendar/change-migration.ts
222
222
  var now4 = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
223
- var EVENT_CHANGE_SNAPSHOT = [
223
+ var V65_SNAPSHOT = [
224
224
  "id",
225
225
  "slug",
226
226
  "title",
@@ -251,7 +251,7 @@ var EVENT_CHANGE_SNAPSHOT = [
251
251
  "created_at",
252
252
  "updated_at"
253
253
  ];
254
- var EVENT_CHANGE_COLUMNS = [
254
+ var V65_COLUMNS = [
255
255
  "slug",
256
256
  "title",
257
257
  "body_json",
@@ -283,12 +283,25 @@ var EVENT_CHANGE_COLUMNS = [
283
283
  "start_at",
284
284
  "end_at"
285
285
  ];
286
- var snapshot = (row) => `json_object(${EVENT_CHANGE_SNAPSHOT.map((c) => `'${c}',${row}.${c}`).join(",")})`;
286
+ var EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, "moved_dates"];
287
+ var EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, "moved_dates"];
288
+ var snapshotOf = (cols, row) => `json_object(${cols.map((c) => `'${c}',${row}.${c}`).join(",")})`;
287
289
  var live = "(SELECT suppressed FROM webhook_control WHERE id=1)=0";
288
290
  var quiet = "COALESCE((SELECT quiet FROM push_control WHERE id=1),0)";
289
291
  var record = (id, before, after) => `INSERT INTO event_changes (event_id,old_row,new_row,quiet,created_at)
290
292
  VALUES (${id},${before},${after},${quiet},${now4});`;
291
- var changed = EVENT_CHANGE_COLUMNS.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
293
+ function eventChangeTriggers(snapshotCols, changedCols) {
294
+ const changed = changedCols.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
295
+ return [
296
+ `CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
297
+ BEGIN ${record("NEW.id", "NULL", snapshotOf(snapshotCols, "NEW"))} END`,
298
+ `CREATE TRIGGER event_changes_update AFTER UPDATE ON events
299
+ WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
300
+ BEGIN ${record("NEW.id", snapshotOf(snapshotCols, "OLD"), snapshotOf(snapshotCols, "NEW"))} END`,
301
+ `CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
302
+ BEGIN ${record("OLD.id", snapshotOf(snapshotCols, "OLD"), "NULL")} END`
303
+ ];
304
+ }
292
305
  var skipped = `CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 OR ${quiet}=1 THEN 'skipped' ELSE 'pending' END`;
293
306
  var ledger = `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
294
307
  SELECT 'event:'||NEW.id,'event',NEW.id,NULL,${skipped},NULL,${now4},${now4}
@@ -302,19 +315,22 @@ var EVENT_CHANGES_MIGRATION = { version: 65, statements: [
302
315
  `CREATE INDEX event_changes_event ON event_changes (event_id, claimed_until)`,
303
316
  `CREATE TABLE push_control (id INTEGER PRIMARY KEY CHECK(id=1), quiet INTEGER NOT NULL DEFAULT 0)`,
304
317
  `INSERT INTO push_control (id,quiet) VALUES (1,0)`,
305
- `CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
306
- BEGIN ${record("NEW.id", "NULL", snapshot("NEW"))} END`,
307
- `CREATE TRIGGER event_changes_update AFTER UPDATE ON events
308
- WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
309
- BEGIN ${record("NEW.id", snapshot("OLD"), snapshot("NEW"))} END`,
310
- `CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
311
- BEGIN ${record("OLD.id", snapshot("OLD"), "NULL")} END`,
318
+ ...eventChangeTriggers(V65_SNAPSHOT, V65_COLUMNS),
312
319
  `DROP TRIGGER push_event_insert`,
313
320
  `DROP TRIGGER push_event_publish`,
314
321
  `CREATE TRIGGER push_event_insert AFTER INSERT ON events WHEN NEW.status='published' BEGIN ${ledger} END`,
315
322
  `CREATE TRIGGER push_event_publish AFTER UPDATE OF status ON events
316
323
  WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${ledger} END`
317
324
  ] };
325
+ var MOVED_DATES_MIGRATION = (contentVersionUpdate) => ({ version: 66, statements: [
326
+ `ALTER TABLE events ADD COLUMN moved_dates TEXT NOT NULL DEFAULT '{}'`,
327
+ `DROP TRIGGER content_version_events_update`,
328
+ contentVersionUpdate,
329
+ `DROP TRIGGER event_changes_insert`,
330
+ `DROP TRIGGER event_changes_update`,
331
+ `DROP TRIGGER event_changes_delete`,
332
+ ...eventChangeTriggers([...V65_SNAPSHOT, "moved_dates"], [...V65_COLUMNS, "moved_dates"])
333
+ ] });
318
334
 
319
335
  // src/lib/maps/migration.ts
320
336
  var PLACES_MIGRATION = { version: 43, statements: [
@@ -566,8 +582,8 @@ var CONTENT_VERSION_COLUMNS = {
566
582
  };
567
583
  var BUMP_CONTENT_VERSION = "UPDATE settings SET content_version = content_version + 1 WHERE id = 1;";
568
584
  function contentVersionTriggers(table, columns) {
569
- const changed2 = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
570
- const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${changed2} BEGIN ${BUMP_CONTENT_VERSION} END`;
585
+ const changed = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
586
+ const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${changed} BEGIN ${BUMP_CONTENT_VERSION} END`;
571
587
  if (table === "settings") return [update];
572
588
  return [
573
589
  `CREATE TRIGGER content_version_${table}_insert AFTER INSERT ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
@@ -1247,12 +1263,15 @@ var MIGRATIONS = [
1247
1263
  ] },
1248
1264
  // every change to a published event, from any write path, queued for its
1249
1265
  // side effects (webhooks, "event changed" pushes, slug redirects)
1250
- EVENT_CHANGES_MIGRATION
1266
+ EVENT_CHANGES_MIGRATION,
1267
+ // single dates of a series moved (CalEvent.movedDates): a column on the
1268
+ // row, so the triggers on events (content version, event_changes) see a move
1269
+ MOVED_DATES_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"])[2])
1251
1270
  ];
1252
1271
  var CURRENT_CONTENT_VERSION_COLUMNS = {
1253
1272
  ...CONTENT_VERSION_COLUMNS,
1254
1273
  settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
1255
- events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates"],
1274
+ events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"],
1256
1275
  resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"],
1257
1276
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
1258
1277
  };
package/docs/calendar.md CHANGED
@@ -49,6 +49,86 @@ stays listed, struck through as "Cancelled", so nobody wonders where it went:
49
49
 
50
50
  The keys are stored in `cancelledDates`. `restoreOccurrence` undoes a cancellation.
51
51
 
52
+ ## Moved dates
53
+
54
+ One date of a series can move to another day, time or room and stay that same date.
55
+ Its key, its sign-ups, its hosts and its page all stay with it. Ways to move a date:
56
+
57
+ - the event editor's **Single dates** list: **Move…** (date, times, rooms and a public
58
+ note), next to **Cancel date**;
59
+ - dragging one date on the Schedule. The week view moves by time as well as day.
60
+ **Undo** and the date's **Move back** put it back;
61
+ - `moveOccurrence` from site code.
62
+
63
+ A moved date:
64
+
65
+ - comes out at its new time everywhere, keeping its original key, with `moved: {
66
+ from: { start, end }, note }`;
67
+ - is labelled **Moved** with its old time and the note on the event page, `/calendar`,
68
+ event cards, the app API (`occurrences[].moved`) and the hosts' My events;
69
+ - takes `?on=` with either day: its new one or the one it moved from;
70
+ - is an override in `calendar.ics` (same UID, `RECURRENCE-ID` of the old date, the new
71
+ `DTSTART`/`DTEND`), so calendar apps move that one date;
72
+ - holds its rooms at the new time: its own `roomIds` when it moved into other rooms,
73
+ else the event's;
74
+ - takes its RSVPs with it. Their start moves too, and each person with a seat gets the
75
+ "new time" email in their own language, with the note and the button to view or
76
+ cancel their spot. Places in line move quietly;
77
+ - gets reminders and "starting soon" notices at the new time;
78
+ - is announced by the "When an event changes" rule, still merged over 10 minutes:
79
+ "Music class · Moved to Wed, Jan 9, 5:00 PM (was Thu, Jan 10, 6:00 PM)". Moving it
80
+ back is announced the same way.
81
+
82
+ A date is moved or cancelled, never both. To move a cancelled date, restore it first.
83
+ Cancelling a moved date cancels it at its new time (its RSVPs too) and drops the move.
84
+ Moving the whole series keeps a moved date at its new time, under its new key.
85
+
86
+ Moves are stored on the event row as `movedDates`, keyed by the original occurrence key:
87
+ `{ start, end, roomIds?, location?, note, by, at }`, with `start`/`end` in wall-clock
88
+ time like the event's own. There's no per-date title. Public JSON shows `start`, `end`
89
+ and `note`. `by` (who moved it: a user id, or `google-sync`) and `at` are for staff.
90
+
91
+ ### Who can move a date
92
+
93
+ `moveOccurrence(repo, who, eventId, key, { start, end?, roomIds?, location?, note? }, ctx, { quiet? })`
94
+ and `moveOccurrenceBack(repo, who, eventId, key, ctx, { quiet? })` check who is acting:
95
+
96
+ - **Staff** with "Create and edit content" may move any date. If another event, booking,
97
+ block or busy calendar takes the rooms at the new time, the move still happens, and
98
+ the result's `clashes` lists what else is there.
99
+ - **`GOOGLE_SYNC`** is a calendar sync acting with staff rights. A clash only warns,
100
+ since the change already happened in Google.
101
+ - **A host of the event** may move its dates while the rooms are free; a taken room
102
+ refuses the move (409). Staff (`members.notify`, else the owners) are emailed about
103
+ each move a host makes.
104
+ - Anyone else gets 404, and nobody signed in gets 401.
105
+
106
+ `end` defaults to the date's own length. `roomIds`, `location` and `note` replace the
107
+ date's own when given, and omitting them keeps what it had. `quiet: true` skips the app
108
+ notice and the staff email, for a sync catching up on changes people already know about.
109
+ The RSVP emails still go, because those people's own sign-ups moved.
110
+
111
+ Moving a date to exactly where it already is (same times, rooms, place and note), or
112
+ moving back a date that isn't moved, changes nothing and returns `changed: false`: no
113
+ write, webhook, push or email. A sync can retry, or re-apply what it already applied,
114
+ safely. Cancelling a date that's already cancelled, or restoring one that isn't, is a
115
+ no-op too, even after the date has passed.
116
+
117
+ Errors carry a `status`, a message safe to show people, and a `code`:
118
+
119
+ | status | code | when |
120
+ |---|---|---|
121
+ | 400 | `one-off` | the event doesn't repeat |
122
+ | 400 | `not-a-date` | the key isn't one of its dates |
123
+ | 400 | `started` | the date has started or passed |
124
+ | 400 | `past` | the new time has passed |
125
+ | 400 | `original-passed` | moving back to a time that has passed |
126
+ | 400 | `bad-time` | the times aren't valid, or don't fit an all-day or timed event |
127
+ | 409 | `cancelled-event` | the whole event is cancelled |
128
+ | 409 | `cancelled-date` | the date is cancelled (restore it first) |
129
+ | 409 | `room-taken` | a host moving into a taken room |
130
+ | 401, 404 | | who is acting |
131
+
52
132
  ## Rooms
53
133
 
54
134
  Rooms, people and equipment are **Resources** (Admin → Calendar → Rooms; they need
package/docs/server.md CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  getRepo, currentUser,
13
13
  listEvents, getEvent, listOccurrences,
14
14
  cancelOccurrence, restoreOccurrence,
15
+ moveOccurrence, moveOccurrenceBack, GOOGLE_SYNC,
15
16
  eventRoomClashes, roomBusy,
16
17
  listMyEvents, listEventRequests, submitEventRequest, updateEventRequest,
17
18
  withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
@@ -33,6 +34,8 @@ getEvent(repo: Repo, id: string): Promise<CalEvent | null>
33
34
  listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number; tag?: string; excludeTag?: string }): Promise<Occurrence[]>
34
35
  cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
35
36
  restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
37
+ moveOccurrence(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, input: MoveInput, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<MoveResult>
38
+ moveOccurrenceBack(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<MoveResult>
36
39
  eventRoomClashes(repo: Repo, event: string | CalEvent, opts?: { horizonDays?: number; now?: Date | number }): Promise<EventClash[]>
37
40
  roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<BusySlot[]>
38
41
 
@@ -48,7 +51,9 @@ setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurre
48
51
 
49
52
  type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
50
53
  interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
51
- interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean; roomIds: string[] }
54
+ 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 } }
55
+ interface MoveInput { start: string; end?: string; roomIds?: string[]; location?: Partial<EventLocation> | null; note?: string }
56
+ interface MoveResult { event: CalEvent; clashes: BusySlot[]; changed: boolean }
52
57
  interface Hold { resourceId: string; eventId: string; key: string; title: string; start: number; end: number; eventStart: number; eventEnd: number }
53
58
  interface EventClash { hold: Hold; other: Hold }
54
59
  interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
@@ -56,8 +61,8 @@ interface WriteContext { url: URL; locals: unknown }
56
61
  ```
57
62
 
58
63
  The types `Repo`, `Role`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOptions`,
59
- `OccurrenceWindow`, `WriteContext`, `EventRequestInput`, `Hold`, `EventClash` and
60
- `BusySlot` are exported too.
64
+ `OccurrenceWindow`, `WriteContext`, `EventRequestInput`, `Hold`, `EventClash`,
65
+ `BusySlot`, `MoveInput` and `MoveResult` are exported too.
61
66
 
62
67
  - **`getRepo(locals)`**: the site's content store for this request. Pass `Astro.locals`
63
68
  (or the endpoint/action's `locals`). `Repo` is an opaque handle; pass it to the other
@@ -128,6 +133,26 @@ another day (the admin's drag-to-move), its cancelled dates move with it. If its
128
133
  or rule is edited instead, keys that no longer match an occurrence are ignored, not an
129
134
  error.
130
135
 
136
+ ## Moved dates
137
+
138
+ - **`moveOccurrence(repo, who, eventId, key, input, ctx, opts?)`**: moves one date of a
139
+ repeating event to `input.start`, wall-clock in the event's timezone (`end` defaults
140
+ to the date's own length). It applies at once, keeps the date's key, moves its RSVPs
141
+ (each person is emailed) and its room hold, and shows it as "Moved" with its old time
142
+ everywhere. It checks `who`: staff ("Create and edit content") and `GOOGLE_SYNC` may
143
+ move any date, and get what else takes its rooms then in `clashes`. A host of the
144
+ event may move it only while its rooms are free (409 otherwise), and staff are
145
+ emailed. `opts.quiet` skips the app notice and that staff email.
146
+ - **`moveOccurrenceBack(...)`**: puts it back where the series has it. Idempotent.
147
+
148
+ Moving a date to where it already is returns `changed: false` without writing or
149
+ sending anything, so retries are safe. Errors carry `status` and `code` (`one-off`,
150
+ `not-a-date`, `started`, `past`, `bad-time`, `cancelled-date`, `room-taken`…; the
151
+ full table is in `npx acms docs calendar`).
152
+
153
+ `npx acms docs calendar` has the details: what follows a moved date, how it shows,
154
+ and the error statuses.
155
+
131
156
  ## Rooms
132
157
 
133
158
  Events hold rooms (`event.roomHold.resourceIds`, the Rooms box in the event editor)
@@ -55,7 +55,7 @@ const date = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: Astr
55
55
  </section>}
56
56
  ```
57
57
 
58
- `calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them. `tag` keeps only events with that tag and `excludeTag` leaves them out (a tag's name in any case, or its slug), e.g. `upcomingEvents(Astro, { tag: 'Class' })` for a Classes page. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
58
+ `calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them, and a date moved to another time carries `moved` (`{ from: { start, end }, note }`) so templates can say "Moved from …". `tag` keeps only events with that tag and `excludeTag` leaves them out (a tag's name in any case, or its slug), e.g. `upcomingEvents(Astro, { tag: 'Class' })` for a Classes page. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
59
59
 
60
60
  Options default to `{ limit: 6, horizonDays: 400 }`. The limit must be a non-negative safe integer and the horizon a positive finite number. Each `UpcomingEvent` contains `{ event, start, end, allDay, days }`, with start/end in Unix milliseconds. Format occurrence timestamps using the site's timezone rather than the authored `event.start` when displaying recurring events. `UpcomingEvent`, `UpcomingEventsOptions`, and `ThemeCalendarConfig` are exported types.
61
61
 
package/integration.mjs CHANGED
@@ -465,6 +465,22 @@ function resolveAnnouncements(announcements, reserved) {
465
465
  if (announcements.pages !== undefined && typeof announcements.pages !== 'boolean') throw new Error('acms: announcements.pages must be true or false');
466
466
  return { enabled: true, base, pages: announcements.pages ?? true, topics: { ...topics } };
467
467
  }
468
+ // acms({ signedRoutes }): the site's own routes that take posts from other
469
+ // servers and check the sender's signature themselves, so the cross-site
470
+ // form check skips them (lib/origin-check.ts). Route patterns as Astro writes
471
+ // them. Admin pages, Astro's own routes (/_actions…) and core's routes can't
472
+ // be listed (acms() checks the last against its route table).
473
+ function resolveSignedRoutes(routes) {
474
+ if (routes === undefined) return [];
475
+ if (!Array.isArray(routes) || routes.length > 20) throw new Error('acms: signedRoutes must be a list of at most 20 route patterns, e.g. [\'/api/sms/[event]\']');
476
+ return routes.map((r, i) => {
477
+ if (!isSitePath(r) || /[?#*]/.test(r) || r.endsWith('/')) {
478
+ throw new Error(`acms: signedRoutes[${i}] must be a route pattern such as "/api/sms/[event]" (no query, wildcard or trailing slash) — got "${r}"`);
479
+ }
480
+ if (/^\/(admin|_)/.test(r)) throw new Error(`acms: signedRoutes[${i}] "${r}" is an admin or Astro route — those always keep the cross-site check`);
481
+ return r;
482
+ });
483
+ }
468
484
  function resolveRoles(roles, members) {
469
485
  if (roles === undefined) return [];
470
486
  if (!Array.isArray(roles)) throw new Error('acms: roles must be an array of { key, label } objects');
@@ -691,6 +707,15 @@ function acmsVitePlugin(userConfig) {
691
707
  * `topics` picks the push topic each kind starts with in the composer, e.g.
692
708
  * `{ registration: 'registrations', help: 'help' }` (otherwise a topic named
693
709
  * after the kind, such as `events` or `help`, else everyone).
710
+ * @param {string[]} [options.signedRoutes]
711
+ * The site's own routes that receive posts from another server and check
712
+ * its signature themselves (an SMS or payment provider's webhook), as route
713
+ * patterns, e.g. `['/api/sms/[event]']`. ACMS refuses cross-site form posts
714
+ * the way Astro's `security.checkOrigin` does (it takes that check over);
715
+ * these routes skip it, since such senders send no Origin header and often
716
+ * post form data or text/plain. List only routes that verify a signature.
717
+ * Admin pages, Actions and ACMS's own routes can't be listed; ACMS's email
718
+ * webhooks already skip it.
694
719
  * @returns {import('astro').AstroIntegration}
695
720
  */
696
721
  export default function acms(options = {}) {
@@ -713,6 +738,7 @@ export default function acms(options = {}) {
713
738
  const roles = resolveRoles(options.roles, members);
714
739
  const permissions = resolvePermissions(options.permissions, roles);
715
740
  const maps = resolveMaps(options.maps);
741
+ const signedRoutes = resolveSignedRoutes(options.signedRoutes);
716
742
  const legal = { enabled: true, operatorName: '', contactEmail: '', address: '', country: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
717
743
  if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','address','country','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
718
744
  legal.country = legal.country.trim().toUpperCase();
@@ -909,6 +935,9 @@ export default function acms(options = {}) {
909
935
  [`${members.base}/events/[id]`, 'members/events/[id].astro'],
910
936
  );
911
937
  }
938
+ // core's own routes keep the check (its signed webhooks are listed in lib/origin-check.ts)
939
+ const builtIn = signedRoutes.find((r) => routes.some(([pattern]) => pattern === r));
940
+ if (builtIn) throw new Error(`acms: signedRoutes "${builtIn}" is an ACMS route — list only the site's own routes`);
912
941
  let cacheDir = '';
913
942
  return {
914
943
  name: 'acms',
@@ -920,6 +949,14 @@ export default function acms(options = {}) {
920
949
  mkdirSync(discoveryDir, {recursive:true});
921
950
  writeFileSync(join(discoveryDir,'acms-project.json'), JSON.stringify({ themeApiVersion:1, collections:userConfig.content.collections, calendar:userConfig.calendar, mobile:userConfig.mobile ?? {}, roles:userConfig.roles, permissions:userConfig.permissions, members:userConfig.members }));
922
951
  }
952
+ // Astro's CSRF origin check (security.checkOrigin, on by default) runs
953
+ // before any middleware and no route can opt out, so signed webhooks
954
+ // from other servers and the apps got its 403. Core's middleware runs
955
+ // the same rule for what a browser can send instead (lib/origin-check.ts).
956
+ // A site that turned the check off in astro.config keeps it off.
957
+ const originCheck = config.security?.checkOrigin !== false;
958
+ userConfig.originCheck = { enabled: originCheck, signedRoutes };
959
+ if (originCheck) updateConfig({ security: { checkOrigin: false } });
923
960
  updateConfig({ vite: { plugins: [acmsVitePlugin(userConfig)] } });
924
961
 
925
962
  // Installed from npm, core's admin islands are source files inside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.47",
3
+ "version": "0.3.49",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -30,7 +30,9 @@ import { loadFeed } from '../lib/calendar/ics';
30
30
  import { NATIVE_ALL } from '../lib/calendar/render';
31
31
  import { collectViewEvents } from '../lib/calendar/data';
32
32
  import { loadBoard } from '../lib/calendar/board';
33
- import { cancelOccurrence, duplicateEvent, moveOccurrence, moveSeries, quickCreate, restoreOccurrence, undoMove } from '../lib/calendar/schedule';
33
+ import { cancelOccurrence, duplicateEvent, moveDateBy, moveSeries, quickCreate, restoreOccurrence, undoMove } from '../lib/calendar/schedule';
34
+ import { moveOccurrence, moveOccurrenceBack, upcomingDates } from '../lib/calendar/moves';
35
+ import type { BusySlot } from '../lib/booking/holds';
34
36
  import { waitUntilFrom } from '../lib/webhooks';
35
37
  import { siteOrigin } from '../lib/seo';
36
38
  import type { CalEvent } from '../lib/schema';
@@ -56,6 +58,21 @@ async function requireAdmin(ctx: ActionAPIContext) {
56
58
  throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
57
59
  }
58
60
  }
61
+ // the person acting, for the rules that check who it is (lib/calendar/moves.ts)
62
+ const actorOf = (access: Awaited<ReturnType<typeof requireCan>>) => ({ id: access.userId, can: (p: string) => access.can(p as Permission) });
63
+ // "Hall is also taken then: Choir · 6:30 – 7:30 PM" ('' when it's free)
64
+ async function clashWarning(repo: Awaited<ReturnType<typeof getRepo>>, clashes: BusySlot[]): Promise<string> {
65
+ if (!clashes.length) return '';
66
+ const [settings, resources] = await Promise.all([repo.getSettings(), repo.listResources()]);
67
+ const names = new Map(resources.map((r) => [r.id, r.name]));
68
+ const time = new Intl.DateTimeFormat(siteLocale(settings), { hour: 'numeric', minute: '2-digit', timeZone: settings.timezone || 'UTC' });
69
+ const what = (c: BusySlot) => c.kind === 'calendar' ? 'its busy calendar' : c.kind === 'block' ? `blocked${c.title ? ` (${c.title})` : ''}` : c.title || 'a booking';
70
+ const first = clashes[0];
71
+ const more = clashes.length > 1 ? ` and ${clashes.length - 1} more` : '';
72
+ let range: string;
73
+ try { range = time.formatRange(first.start, first.end); } catch { range = `${time.format(first.start)} – ${time.format(first.end)}`; }
74
+ return `${names.get(first.resourceId) ?? 'The room'} is also taken then: ${what(first)} · ${range}${more}. Both keep the room.`;
75
+ }
59
76
  // …and that their role grants the permission (Team → Roles)
60
77
  async function requireCan(ctx: ActionAPIContext, permission: Permission) {
61
78
  const access = await accessFor(ctx.request, ctx.cookies, await getRepo(ctx.locals));
@@ -1134,28 +1151,76 @@ export const server = {
1134
1151
  try { const e = await quickCreate(repo, input, await publishHooks(ctx)); return { id: e.id, status: e.status }; } catch (e) { asActionError(e); }
1135
1152
  },
1136
1153
  }),
1137
- // Schedule: drag to another day. `occurrence` + mode 'one' moves a single
1138
- // repeat; otherwise the whole event moves.
1154
+ // Schedule: drag to another day (and, in the week view, another time:
1155
+ // `minutes` more). `occurrence` + mode 'one' moves a single date of a
1156
+ // series (a moved date that keeps its RSVPs); otherwise the whole event
1157
+ // moves. `warning`: what else takes its rooms at the new time.
1139
1158
  move: defineAction({
1140
- input: z.object({ id: z.string(), days: z.number().int(), mode: z.enum(['series', 'one']).default('series'), occurrence: z.string().max(20).default('') }),
1141
- handler: async ({ id, days, mode, occurrence }, ctx) => {
1142
- await requireCan(ctx, 'content.edit');
1159
+ input: z.object({ id: z.string(), days: z.number().int(), minutes: z.number().int().min(-1440).max(1440).default(0), mode: z.enum(['series', 'one']).default('series'), occurrence: z.string().max(20).default('') }),
1160
+ handler: async ({ id, days, minutes, mode, occurrence }, ctx) => {
1161
+ const who = actorOf(await requireCan(ctx, 'content.edit'));
1143
1162
  const repo = await getRepo(ctx.locals);
1144
1163
  try {
1145
- const r = mode === 'one' ? await moveOccurrence(repo, id, occurrence, days, await publishHooks(ctx)) : await moveSeries(repo, id, days, await publishHooks(ctx));
1146
- return { id: r.event.id, undo: r.undo };
1164
+ if (mode === 'one') {
1165
+ const r = await moveDateBy(repo, who, id, occurrence, days * 1440 + minutes, await publishHooks(ctx));
1166
+ return { id: r.event.id, undo: r.undo, warning: await clashWarning(repo, r.clashes) };
1167
+ }
1168
+ const r = await moveSeries(repo, id, days, await publishHooks(ctx), minutes);
1169
+ return { id: r.event.id, undo: r.undo, warning: '' };
1147
1170
  } catch (e) { asActionError(e); }
1148
1171
  },
1149
1172
  }),
1150
1173
  undoMove: defineAction({
1151
1174
  input: z.union([
1152
- z.object({ kind: z.literal('series'), id: z.string(), days: z.number().int() }),
1175
+ z.object({ kind: z.literal('series'), id: z.string(), days: z.number().int(), minutes: z.number().int().optional() }),
1176
+ z.object({ kind: z.literal('date'), id: z.string(), key: z.string().max(20), previous: z.object({
1177
+ start: z.string().max(20), end: z.string().max(20), roomIds: z.array(z.string()).max(50).optional(), note: z.string().max(500).default(''),
1178
+ location: z.object({ name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(2000).default('') }).partial().optional(),
1179
+ }).nullable() }),
1153
1180
  z.object({ kind: z.literal('split'), id: z.string(), seriesId: z.string(), occurrence: z.string().max(20) }),
1154
1181
  ]),
1155
1182
  handler: async (undo, ctx) => {
1156
- await requireCan(ctx, 'content.edit');
1183
+ const who = actorOf(await requireCan(ctx, 'content.edit'));
1184
+ const repo = await getRepo(ctx.locals);
1185
+ try { await undoMove(repo, who, undo as Parameters<typeof undoMove>[2], await publishHooks(ctx)); return { ok: true }; } catch (e) { asActionError(e); }
1186
+ },
1187
+ }),
1188
+ // Event editor → Dates: move one date (new date, time, rooms and a public
1189
+ // note), at once like cancelling it. `warning`: what else takes its rooms then.
1190
+ moveDate: defineAction({
1191
+ input: z.object({ id: z.string(), key: z.string().max(20), start: z.string().max(20), end: z.string().max(20).default(''),
1192
+ roomIds: z.array(z.string()).max(50).optional(), note: z.string().max(500).default('') }),
1193
+ handler: async ({ id, key, start, end, roomIds, note }, ctx) => {
1194
+ const who = actorOf(await requireCan(ctx, 'content.edit'));
1195
+ const repo = await getRepo(ctx.locals);
1196
+ try {
1197
+ const r = await moveOccurrence(repo, who, id, key, { start, end: end || undefined, roomIds, note }, await publishHooks(ctx));
1198
+ return { id, warning: await clashWarning(repo, r.clashes) };
1199
+ } catch (e) { asActionError(e); }
1200
+ },
1201
+ }),
1202
+ // …and put a moved date back where the series has it
1203
+ moveDateBack: defineAction({
1204
+ input: z.object({ id: z.string(), key: z.string().max(20) }),
1205
+ handler: async ({ id, key }, ctx) => {
1206
+ const who = actorOf(await requireCan(ctx, 'content.edit'));
1207
+ const repo = await getRepo(ctx.locals);
1208
+ try {
1209
+ const r = await moveOccurrenceBack(repo, who, id, key, await publishHooks(ctx));
1210
+ return { id, warning: await clashWarning(repo, r.clashes) };
1211
+ } catch (e) { asActionError(e); }
1212
+ },
1213
+ }),
1214
+ // Event editor → Single dates: the next dates of a series (as saved), each
1215
+ // moved, cancelled or as planned, and the rooms a date can move into
1216
+ dates: defineAction({
1217
+ input: z.object({ id: z.string(), limit: z.number().int().min(1).max(52).default(12) }),
1218
+ handler: async ({ id, limit }, ctx) => {
1219
+ await requireAdmin(ctx);
1157
1220
  const repo = await getRepo(ctx.locals);
1158
- try { await undoMove(repo, undo, await publishHooks(ctx)); return { ok: true }; } catch (e) { asActionError(e); }
1221
+ const [e, settings, resources] = await Promise.all([repo.getEvent(id), repo.getSettings(), bookingsEnabled() ? repo.listResources() : Promise.resolve([])]);
1222
+ if (!e) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
1223
+ return { allDay: e.allDay, roomIds: e.roomHold.resourceIds, dates: upcomingDates(e, settings, limit), rooms: resources.map((r) => ({ id: r.id, name: r.name })) };
1159
1224
  },
1160
1225
  }),
1161
1226
  // Schedule: cancel (or restore) one date of a repeating event, at once