@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.
- package/CHANGELOG.md +56 -0
- package/cli/migrations.generated.mjs +42 -23
- package/docs/calendar.md +80 -0
- package/docs/server.md +28 -3
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +37 -0
- package/package.json +1 -1
- package/src/actions/index.ts +76 -11
- package/src/components/EventPage.astro +18 -12
- package/src/components/calendar/CalendarBoard.tsx +43 -19
- package/src/components/editor/event/EventSheet.tsx +2 -0
- package/src/components/editor/event/SingleDates.tsx +123 -0
- package/src/lib/booking/holds.ts +7 -7
- package/src/lib/calendar/api.ts +11 -1
- package/src/lib/calendar/board.ts +7 -3
- package/src/lib/calendar/cards.ts +5 -5
- package/src/lib/calendar/change-migration.ts +40 -15
- package/src/lib/calendar/data.ts +9 -3
- package/src/lib/calendar/event-words.ts +9 -0
- package/src/lib/calendar/events.ts +94 -9
- package/src/lib/calendar/ics-write.ts +25 -2
- package/src/lib/calendar/moves.ts +291 -0
- package/src/lib/calendar/render.ts +10 -6
- package/src/lib/calendar/schedule.ts +73 -61
- package/src/lib/email/booking-words.ts +7 -1
- package/src/lib/email/service.ts +6 -1
- package/src/lib/email/templates.ts +6 -2
- package/src/lib/mobile/calendar.ts +5 -2
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/origin-check.ts +54 -0
- package/src/lib/push/service.ts +44 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +28 -10
- package/src/lib/repo/types.ts +6 -3
- package/src/lib/schema.ts +21 -0
- package/src/lib/sections.ts +5 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/middleware.ts +5 -0
- package/src/pages/api/calendar/events/index.ts +6 -0
- package/src/pages/members/_Layout.astro +1 -0
- package/src/pages/members/events/index.astro +1 -1
- package/src/server/index.ts +33 -0
- package/src/styles/editor.css +19 -0
- package/src/styles/schedule.css +2 -0
- package/src/styles/site.css +6 -0
- package/src/theme-sdk/calendar.ts +3 -2
- 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
|
|
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
|
|
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}',${
|
|
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 (${
|
|
98
|
+
`(OLD.status='published' OR NEW.status='published') AND (${changed})`,
|
|
99
99
|
event,
|
|
100
|
-
`json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${
|
|
101
|
-
ELSE json_set(${
|
|
102
|
-
'previous',json(CASE WHEN OLD.status='published' THEN ${
|
|
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}',${
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
570
|
-
const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${
|
|
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
|
|
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)
|
package/docs/theme-authoring.md
CHANGED
|
@@ -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
package/src/actions/index.ts
CHANGED
|
@@ -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,
|
|
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
|
|
1138
|
-
//
|
|
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
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
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
|