@acms-subir/core 0.3.44 → 0.3.46

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 (47) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/cli/main.mjs +3 -2
  3. package/cli/migrations.generated.mjs +128 -16
  4. package/docs/calendar.md +112 -0
  5. package/docs/environment-sync.md +6 -0
  6. package/docs/mobile.md +1 -0
  7. package/docs/server.md +47 -2
  8. package/integration.mjs +2 -0
  9. package/package.json +1 -1
  10. package/src/actions/index.ts +16 -8
  11. package/src/components/calendar/QuickAdd.tsx +1 -1
  12. package/src/components/calendar/RoomHolds.tsx +23 -5
  13. package/src/lib/booking/holds.ts +98 -2
  14. package/src/lib/booking/service.ts +2 -1
  15. package/src/lib/calendar/agent.ts +5 -5
  16. package/src/lib/calendar/api.ts +16 -3
  17. package/src/lib/calendar/change-migration.ts +65 -0
  18. package/src/lib/calendar/data.ts +12 -1
  19. package/src/lib/calendar/events.ts +4 -2
  20. package/src/lib/calendar/hooks.ts +76 -31
  21. package/src/lib/calendar/requests.ts +2 -3
  22. package/src/lib/calendar/schedule.ts +4 -4
  23. package/src/lib/content-service.ts +15 -12
  24. package/src/lib/editor/schedule.ts +3 -0
  25. package/src/lib/mobile/calendar.ts +49 -11
  26. package/src/lib/mobile/openapi.ts +3 -1
  27. package/src/lib/mobile/schemas.ts +8 -3
  28. package/src/lib/mobile/search.ts +5 -6
  29. package/src/lib/push/service.ts +1 -1
  30. package/src/lib/repo/migrations.ts +15 -0
  31. package/src/lib/repo/sql.ts +36 -5
  32. package/src/lib/repo/types.ts +21 -0
  33. package/src/lib/scheduler.ts +3 -0
  34. package/src/lib/schema.ts +10 -0
  35. package/src/lib/sync/catalog.ts +2 -0
  36. package/src/lib/sync/store.ts +4 -0
  37. package/src/middleware.ts +13 -2
  38. package/src/pages/admin/calendar/events/index.astro +4 -9
  39. package/src/pages/admin/calendar/resources/[id].astro +13 -1
  40. package/src/pages/api/calendar/docs.ts +2 -0
  41. package/src/pages/api/calendar/events/[id].ts +5 -5
  42. package/src/pages/api/calendar/events/index.ts +7 -4
  43. package/src/pages/api/v1/rooms/free.ts +7 -0
  44. package/src/pages/api/webhooks/docs.ts +5 -0
  45. package/src/server/index.ts +37 -0
  46. package/src/styles/admin.css +3 -0
  47. package/src/styles/editor.css +2 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.46
4
+
5
+ ### Patch Changes
6
+
7
+ - de6694e: Event webhooks, the "When an event changes" push and slug redirects now follow every change to a published event, however it's made. Before, only the event editor, the Schedule and the list's bulk actions ran them. A scheduled event that went live sent no `event.published`, a saved place's new address sent no `event.updated` and no push, and an environment sync sent no event webhooks. Now database triggers record each write to a published event in the same transaction, and the side effects run right after the request (or from the scheduler, for scheduled publishing).
8
+
9
+ - **Scheduled publishing** behaves like pressing Publish: `event.published`, with the same payload.
10
+ - **Agent tools and the REST API**: unchanged on the surface. Edits to a live event still wait as a staged draft, and `publish_event` (or `"publish": true`) and DELETE send their webhooks.
11
+ - **A saved place's new name or address**: `event.updated`, and the "When an event changes" push for the new place (people with an RSVP already got the email).
12
+ - **Environment sync**: sends the event webhooks but never member pushes for events. An event a run publishes counts as already announced (no "new event" push), and its date, time or place changes send no change push. Posts a run publishes keep their "new post" push.
13
+ - **Restores** still send only `site.restored`. Changes still waiting from before the restore are dropped.
14
+ - **Drafts** (new events, ICS/CSV imports, Duplicate, "Make an editable copy", members' requests) send nothing until they're published.
15
+ - `event.updated` now means something public changed: times, place, cancelled dates, text, translations, calendars, rooms, RSVP settings. Saving only hosts or a member's request, or publishing an event that's already published, no longer sends it. Each write sends its own webhook, so moving a series that has cancelled dates sends two `event.updated`, the second with the moved cancelled dates (before, the one webhook had the old ones).
16
+
17
+ Payloads are unchanged. Migration 65 adds the `event_changes` table (with its triggers on `events`) and `push_control`, and rebuilds the events' first-publish push triggers. Other features can keep their own queues from their own triggers on `events`. The repository has new `claimEventChanges` and `finishEventChanges`.
18
+
19
+ ## 0.3.45
20
+
21
+ ### Patch Changes
22
+
23
+ - 318e6a1: Rooms: clashes between events, a public free/busy check, and room colours and names in public event data.
24
+
25
+ **Clashes.** Room bookings already couldn't take a room an event holds, but two events could hold the same room at once with no warning. The event editor's Rooms box now warns when another event holds a ticked room at an overlapping time, setup and cleanup included. It shows that event's title (linking to it) and date, for example "Hall is also held by another event · Practice · Thu, Jan 10 · 7:00 – 8:00 PM". Both events keep the room, so the warning says to change a time or pick another room if they can't share it. The room list says "Another event" or "Busy on 3 of 12 dates", and Quick add counts these as busy too. Other events count while they hold rooms: published or scheduled, drafts with Hold now, and not on a cancelled date. New in `@acms-subir/core/server`:
26
+
27
+ - `eventRoomClashes(repo, eventOrId, { horizonDays?, now? })`: the other events holding the same rooms at overlapping times, as `{ hold, other }` pairs.
28
+ - `roomBusy(repo, { roomIds, from, to, ignore? })`: everything that takes the rooms in a span (event holds, bookings, staff blocks and busy calendars), with names for staff. `ignore: { eventId, key }` leaves out the date being moved.
29
+
30
+ Neither function checks permissions. Holds now carry the date's occurrence `key`, and occurrences carry `roomIds`, the rooms that date holds.
31
+
32
+ **Public free/busy.** `GET /api/v1/rooms/free?room=<slug>&start=&end=` answers `{ room, start, end, free, busy: [{ start, end }] }`. The busy times are merged and clipped to the span and never say what or who. Only rooms with the new **Anyone can check if it’s free** switch answer; any other slug is a 404. Spans are at most 31 days, answers are never cached, and each address gets 30 checks a minute.
33
+
34
+ **Room colour and names.** Each room (Admin → Calendar → Rooms) gets:
35
+
36
+ - a **Colour**, for events in it on calendars and screens;
37
+ - **Name it on public events**, off by default, so existing rooms stay unnamed until staff turn it on.
38
+
39
+ Public event data now has `rooms: [{ slug, name, color }]`, listing only rooms with that switch on, with `color` `null` when a room has none. It appears in `/api/v1/events` (on each event and on each occurrence, the rooms that date holds), `/api/v1/events/:slug`, `/api/v1/search`, `/api/calendar/events` and webhook payloads. An API key with `events:read` and the agent tools see every room. Calendar views (`collectViewEvents`: `/calendar`, cards, embeds) give each native event `rooms: [{ id, slug, name, color }]` under the same rule. The editor API's resources take `color`, `showOnEvents` and `requestable`, and environment sync carries them.
40
+
41
+ Migration 64 adds `resources.color`, `show_on_events` and `requestable`; changing the colour or the naming switch refreshes cached pages and API answers. New docs: `npx acms docs calendar` covers repeats, cancelled dates, rooms and clashes, and `npx acms docs server` has the two new functions.
42
+
3
43
  ## 0.3.44
4
44
 
5
45
  ### Patch Changes
package/cli/main.mjs CHANGED
@@ -10,7 +10,7 @@ import { addMobile, mobileCommand, listApps } from './mobile.mjs';
10
10
  import { mobileDoctor, renderDoctor } from './mobile-doctor.mjs';
11
11
  import { monitorCommand, renderMonitor } from './monitor.mjs';
12
12
  const HELP=`acms — ACMS project, theme and agent tools
13
- Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server
13
+ Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server | docs calendar
14
14
  agent init [--mobile] write .agents/skills (theme; mobile when an app exists)
15
15
  Check: check [--build] validate every project theme, refresh section types,
16
16
  report sections without a native app version
@@ -140,6 +140,7 @@ export async function main() {
140
140
  else if(cmd==='docs'&&sub==='mobile')data={markdown:await readFile(resolve(CORE,'docs/mobile.md'),'utf8')};
141
141
  else if(cmd==='docs'&&sub==='sync')data={markdown:await readFile(resolve(CORE,'docs/environment-sync.md'),'utf8')};
142
142
  else if(cmd==='docs'&&sub==='server')data={markdown:await readFile(resolve(CORE,'docs/server.md'),'utf8')};
143
+ else if(cmd==='docs'&&sub==='calendar')data={markdown:await readFile(resolve(CORE,'docs/calendar.md'),'utf8')};
143
144
  else if(cmd==='section')data=await sectionCommand(sub,rest,flags);
144
145
  else if(cmd==='check') {
145
146
  const p=await project(),themes=(await listLocalThemes(p)).filter(t=>t.origin==='project');
@@ -148,7 +149,7 @@ export async function main() {
148
149
  }
149
150
  else if(cmd==='agent'&&sub==='init')data=await agentInit(flags);
150
151
  else if(cmd==='inspect') {
151
- const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor','monitor-kuma','migrate','server-api'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
152
+ const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor','monitor-kuma','migrate','server-api','calendar-docs'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
152
153
  } else if(cmd==='theme')data=await themeCommand(sub,rest,flags);
153
154
  else if(['site','design','content','media'].includes(cmd))data=await remoteCommand(cmd,sub,rest,flags);
154
155
  else fail('USAGE','Unknown command.',[],['Run acms --help']);
@@ -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 snapshot = (row) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
87
+ const snapshot2 = (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 changed = fields.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(" OR ");
89
+ const changed2 = 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}',${snapshot("NEW")})`),
93
+ trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot2("NEW")})`),
94
94
  trigger(
95
95
  `webhook_${kind}_update`,
96
96
  "UPDATE",
97
97
  table,
98
- `(OLD.status='published' OR NEW.status='published') AND (${changed})`,
98
+ `(OLD.status='published' OR NEW.status='published') AND (${changed2})`,
99
99
  event,
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))`
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))`
103
103
  ),
104
- trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot("OLD")})`)
104
+ trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot2("OLD")})`)
105
105
  ];
106
106
  }
107
107
  var media = (row) => `json_object('media',json_object('id',${row}.id,'key',${row}.key,'filename',${row}.filename,
@@ -218,6 +218,104 @@ var ROOM_HOLD_MIGRATION = { version: 41, statements: [
218
218
  `ALTER TABLE events ADD COLUMN room_hold TEXT NOT NULL DEFAULT '{}'`
219
219
  ] };
220
220
 
221
+ // src/lib/calendar/change-migration.ts
222
+ var now4 = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
223
+ var EVENT_CHANGE_SNAPSHOT = [
224
+ "id",
225
+ "slug",
226
+ "title",
227
+ "excerpt",
228
+ "cover",
229
+ "cover_alt",
230
+ "status",
231
+ "featured",
232
+ "tags",
233
+ "starts",
234
+ "ends",
235
+ "all_day",
236
+ "timezone",
237
+ "rrule",
238
+ "exdates",
239
+ "cancelled",
240
+ "cancelled_dates",
241
+ "location",
242
+ "cta",
243
+ "calendar_ids",
244
+ "fields",
245
+ "rsvp",
246
+ "room_hold",
247
+ "start_at",
248
+ "end_at",
249
+ "published_at",
250
+ "scheduled_at",
251
+ "created_at",
252
+ "updated_at"
253
+ ];
254
+ var EVENT_CHANGE_COLUMNS = [
255
+ "slug",
256
+ "title",
257
+ "body_json",
258
+ "body_html",
259
+ "excerpt",
260
+ "cover",
261
+ "cover_alt",
262
+ "seo_title",
263
+ "seo_desc",
264
+ "status",
265
+ "featured",
266
+ "tags",
267
+ "template",
268
+ "starts",
269
+ "ends",
270
+ "all_day",
271
+ "timezone",
272
+ "rrule",
273
+ "exdates",
274
+ "cancelled",
275
+ "cancelled_dates",
276
+ "location",
277
+ "cta",
278
+ "calendar_ids",
279
+ "fields",
280
+ "rsvp",
281
+ "translations",
282
+ "room_hold",
283
+ "start_at",
284
+ "end_at"
285
+ ];
286
+ var snapshot = (row) => `json_object(${EVENT_CHANGE_SNAPSHOT.map((c) => `'${c}',${row}.${c}`).join(",")})`;
287
+ var live = "(SELECT suppressed FROM webhook_control WHERE id=1)=0";
288
+ var quiet = "COALESCE((SELECT quiet FROM push_control WHERE id=1),0)";
289
+ var record = (id, before, after) => `INSERT INTO event_changes (event_id,old_row,new_row,quiet,created_at)
290
+ VALUES (${id},${before},${after},${quiet},${now4});`;
291
+ var changed = EVENT_CHANGE_COLUMNS.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
292
+ var skipped = `CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 OR ${quiet}=1 THEN 'skipped' ELSE 'pending' END`;
293
+ var ledger = `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
294
+ SELECT 'event:'||NEW.id,'event',NEW.id,NULL,${skipped},NULL,${now4},${now4}
295
+ WHERE NOT EXISTS (SELECT 1 FROM push_queue WHERE key='event:'||NEW.id);`;
296
+ var EVENT_CHANGES_MIGRATION = { version: 65, statements: [
297
+ `CREATE TABLE event_changes (
298
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL, old_row TEXT, new_row TEXT,
299
+ quiet INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, claimed_until INTEGER NOT NULL DEFAULT 0,
300
+ created_at INTEGER NOT NULL
301
+ )`,
302
+ `CREATE INDEX event_changes_event ON event_changes (event_id, claimed_until)`,
303
+ `CREATE TABLE push_control (id INTEGER PRIMARY KEY CHECK(id=1), quiet INTEGER NOT NULL DEFAULT 0)`,
304
+ `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`,
312
+ `DROP TRIGGER push_event_insert`,
313
+ `DROP TRIGGER push_event_publish`,
314
+ `CREATE TRIGGER push_event_insert AFTER INSERT ON events WHEN NEW.status='published' BEGIN ${ledger} END`,
315
+ `CREATE TRIGGER push_event_publish AFTER UPDATE OF status ON events
316
+ WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${ledger} END`
317
+ ] };
318
+
221
319
  // src/lib/maps/migration.ts
222
320
  var PLACES_MIGRATION = { version: 43, statements: [
223
321
  `CREATE TABLE IF NOT EXISTS places (
@@ -468,8 +566,8 @@ var CONTENT_VERSION_COLUMNS = {
468
566
  };
469
567
  var BUMP_CONTENT_VERSION = "UPDATE settings SET content_version = content_version + 1 WHERE id = 1;";
470
568
  function contentVersionTriggers(table, columns) {
471
- const changed = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
472
- const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${changed} BEGIN ${BUMP_CONTENT_VERSION} END`;
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`;
473
571
  if (table === "settings") return [update];
474
572
  return [
475
573
  `CREATE TRIGGER content_version_${table}_insert AFTER INSERT ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
@@ -1136,12 +1234,26 @@ var MIGRATIONS = [
1136
1234
  // with or without the answers); '{}' = off. Not public, so no cache trigger.
1137
1235
  { version: 63, statements: [
1138
1236
  `ALTER TABLE forms ADD COLUMN alerts TEXT NOT NULL DEFAULT '{}'`
1139
- ] }
1237
+ ] },
1238
+ // rooms: a colour and whether public event data names them
1239
+ // (Resource.color / .showOnEvents, both visible), and whether anyone may ask
1240
+ // if they're free (Resource.requestable, never shown, so no cache bump)
1241
+ { version: 64, statements: [
1242
+ `ALTER TABLE resources ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
1243
+ `ALTER TABLE resources ADD COLUMN show_on_events INTEGER NOT NULL DEFAULT 0`,
1244
+ `ALTER TABLE resources ADD COLUMN requestable INTEGER NOT NULL DEFAULT 0`,
1245
+ `DROP TRIGGER content_version_resources_update`,
1246
+ contentVersionTriggers("resources", [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"])[2]
1247
+ ] },
1248
+ // every change to a published event, from any write path, queued for its
1249
+ // side effects (webhooks, "event changed" pushes, slug redirects)
1250
+ EVENT_CHANGES_MIGRATION
1140
1251
  ];
1141
1252
  var CURRENT_CONTENT_VERSION_COLUMNS = {
1142
1253
  ...CONTENT_VERSION_COLUMNS,
1143
1254
  settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
1144
1255
  events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates"],
1256
+ resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"],
1145
1257
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
1146
1258
  };
1147
1259
  var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -1164,7 +1276,7 @@ async function pendingMigrations(db) {
1164
1276
  return { applied, pending: MIGRATIONS.filter((m) => m.version > applied) };
1165
1277
  }
1166
1278
  async function runMigrations(db, opts = {}) {
1167
- const now4 = opts.now ?? Date.now;
1279
+ const now5 = opts.now ?? Date.now;
1168
1280
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1169
1281
  const first = await pendingMigrations(db);
1170
1282
  if (!first.pending.length) return { from: first.applied, to: first.applied, applied: [] };
@@ -1172,10 +1284,10 @@ async function runMigrations(db, opts = {}) {
1172
1284
  if (refused) return { from: first.applied, to: first.applied, applied: [], deferred: refused.version };
1173
1285
  await db.run(MIGRATION_LOCK_TABLE);
1174
1286
  const owner = crypto.randomUUID();
1175
- const deadline = now4() + (opts.waitMs ?? 6e4);
1287
+ const deadline = now5() + (opts.waitMs ?? 6e4);
1176
1288
  let waited = false;
1177
1289
  for (; ; ) {
1178
- const t = now4();
1290
+ const t = now5();
1179
1291
  await db.run(
1180
1292
  `INSERT INTO migration_lock (id, owner, expires_at) VALUES (1, ?, ?)
1181
1293
  ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at WHERE migration_lock.expires_at < ?`,
@@ -1193,13 +1305,13 @@ async function runMigrations(db, opts = {}) {
1193
1305
  try {
1194
1306
  const { applied, pending } = await pendingMigrations(db);
1195
1307
  if (applied === 1 && !await appliedVersion(db)) {
1196
- await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [now4()]);
1308
+ await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [now5()]);
1197
1309
  }
1198
1310
  for (const m of pending) {
1199
1311
  opts.log?.(`Applying migration ${m.version}`);
1200
1312
  await db.batch([
1201
1313
  ...m.statements.map((sql) => ({ sql })),
1202
- { sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version, now4()] }
1314
+ { sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version, now5()] }
1203
1315
  ]);
1204
1316
  }
1205
1317
  return { from: applied, to: pending.length ? pending[pending.length - 1].version : applied, applied: pending.map((m) => m.version) };
@@ -0,0 +1,112 @@
1
+ # Calendar: repeats, single dates, rooms and clashes
2
+
3
+ How events repeat, how one date of a series is changed, and how events hold rooms.
4
+ Read it with `npx acms docs calendar`. The functions named here come from
5
+ `@acms-subir/core/server` (`npx acms docs server` has their signatures), and the app
6
+ API is in `docs/mobile-api-v1.md` in the framework repository.
7
+
8
+ ## Times and repeats
9
+
10
+ An event stores **wall-clock** times in its own timezone (the site's when it has
11
+ none): `start` and `end` are `YYYY-MM-DDTHH:mm`, or `YYYY-MM-DD` for all-day events,
12
+ whose `end` is the last day, inclusive. A 6 PM class stays at 6 PM across daylight
13
+ saving changes.
14
+
15
+ A repeating event has an `rrule`, the RFC 5545 rule without the `RRULE:` prefix, such
16
+ as `FREQ=WEEKLY;BYDAY=TH`. The editor offers daily, weekly on chosen days, monthly by
17
+ date or by weekday ("second Tuesday"), and yearly, each ending never, on a date or
18
+ after a number of times. Other rules round-trip as custom rules. Every repeat is the
19
+ same event: one page, one slug, the same hosts and rooms. The calendar, the app API,
20
+ the ICS feeds and room holds expand it into **occurrences** (dates) as needed.
21
+
22
+ `exdates` skips dates entirely: they vanish from the calendar and feeds. Dragging a
23
+ whole series on the admin Schedule moves the rule with it ("weekly on Thursday" moved a
24
+ day becomes "weekly on Friday").
25
+
26
+ ## Occurrence keys
27
+
28
+ Each date of a series has a **key**: its start as wall-clock time in the event's
29
+ timezone, in the same format as `start` (`2030-01-10T18:00`, or `2030-01-10` for
30
+ all-day events). Changes to single dates are stored on the event under that key. Take
31
+ keys from `listOccurrences` (`o.key`) rather than building them.
32
+
33
+ When the series moves to another day, stored keys move with it. When its time or rule
34
+ is edited instead, keys that no longer match a date are ignored, not an error.
35
+
36
+ ## Cancelled dates
37
+
38
+ Cancelling one date (**Cancel this date** in the editor, the Schedule, a host's My
39
+ events, or `cancelOccurrence`) applies at once, even on a published event. The date
40
+ stays listed, struck through as "Cancelled", so nobody wonders where it went:
41
+
42
+ - the event page, `/calendar`, `/api/v1/events` and `/api/calendar/events` mark it
43
+ `cancelled`;
44
+ - the ICS feeds add an override for that date with `STATUS:CANCELLED`;
45
+ - its room holds are released for that slot;
46
+ - its RSVPs are cancelled, and each person gets the "cancelled" email. Restoring the
47
+ date doesn't bring them back, so people sign up again;
48
+ - the "When an event changes" push rule can announce it.
49
+
50
+ The keys are stored in `cancelledDates`. `restoreOccurrence` undoes a cancellation.
51
+
52
+ ## Rooms
53
+
54
+ Rooms, people and equipment are **Resources** (Admin → Calendar → Rooms; they need
55
+ `calendar.bookings`, which is on by default). An event **holds** rooms
56
+ (`roomHold.resourceIds`, the Rooms box in the editor) while it happens, plus any setup
57
+ and cleanup minutes, or all day:
58
+
59
+ - Published and scheduled events hold their rooms. A draft holds them only with **Hold
60
+ now** on. A cancelled event, or a cancelled date, holds nothing.
61
+ - Holds are worked out from the events every time and never stored, so moving,
62
+ repeating, skipping or cancelling a date frees or moves the room automatically.
63
+ - Room bookings (`/book`, embeds, the app, the API) can't take a held room.
64
+
65
+ Each room also has:
66
+
67
+ - **Colour** (`color`, `#rrggbb` or `''`): for events in that room on calendars and
68
+ screens.
69
+ - **Name it on public events** (`showOnEvents`, off by default): public event data
70
+ names the room. When it's off, only staff see that an event uses the room.
71
+ - **Anyone can check if it’s free** (`requestable`, off by default): the room answers
72
+ the public free/busy check below.
73
+
74
+ ### Rooms in event data
75
+
76
+ - `/api/v1/events`, `/api/v1/events/:slug`, `/api/v1/search`, `/api/calendar/events`
77
+ and webhook payloads carry `rooms: [{ slug, name, color }]` on each event (`color`
78
+ is `null` when the room has none). Each occurrence also has `rooms`, the rooms that
79
+ date holds. Only rooms with **Name it on public events** appear. Staff callers (an
80
+ API key with `events:read`, the agent tools) see every room.
81
+ - Calendar views built on `collectViewEvents` (the `/calendar` page, calendar cards,
82
+ embeds) get `rooms: [{ id, slug, name, color }]` on each native event's
83
+ occurrence, with the same rule for public views. Admin views get every room.
84
+ - Site code reads `event.roomHold.resourceIds`, or `o.roomIds` on an occurrence.
85
+
86
+ ### Clashes
87
+
88
+ Bookings can't take a held room, but two **events** may hold the same room at once.
89
+ The event editor's Rooms box warns when another event holds a ticked room at an
90
+ overlapping time, with that event's title and date. Both events keep the room, so
91
+ change a time or pick another room if they can't share it. The same box still lists
92
+ bookings the event would overlap, with Keep or Cancel and email.
93
+
94
+ Site code and other framework features use the same checks:
95
+
96
+ - `eventRoomClashes(repo, eventOrId)`: the other events holding this event's rooms at
97
+ overlapping times over the next year, as pairs of `{ hold, other }` (one date of each).
98
+ - `roomBusy(repo, { roomIds, from, to, ignore? })`: everything that takes the rooms in
99
+ a span (event holds, bookings, staff blocks and the rooms' busy calendars), with
100
+ names. `ignore: { eventId, key }` leaves out the date you're about to move.
101
+
102
+ Both include names, so show them to staff only. Neither checks permissions.
103
+
104
+ ### Public free/busy
105
+
106
+ `GET /api/v1/rooms/free?room=<slug>&start=<day or date-time>&end=<…>` answers
107
+ whether a room is free, plus the busy times inside the span (merged), never what or
108
+ who. Only rooms with **Anyone can check if it’s free** answer, and every other slug is
109
+ a 404. Spans are at most 31 days, and a plain day means midnight in the site's
110
+ timezone. Answers are never cached, and each address gets 30 checks a minute. The
111
+ client has `roomFree({ room, start, end })`, and the native package has
112
+ `useRoomFree(query)`.
@@ -221,6 +221,12 @@ This publishes only the draft-capable items in that run. It rejects later edits
221
221
  to those items instead of implicitly publishing somebody else's changes.
222
222
  Design/layout changes conservatively share a settings guard.
223
223
 
224
+ What a run publishes or changes sends the destination's webhooks, as an edit
225
+ there would (`post.*`, `page.*`, `event.*`). It never sends app push
226
+ notifications for events: an event a run publishes counts as already
227
+ announced, and its date, time or place changes send no "When an event changes"
228
+ notice. Posts a run publishes still get the "new post" notice.
229
+
224
230
  ## History and rollback
225
231
 
226
232
  ```sh
package/docs/mobile.md CHANGED
@@ -127,6 +127,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
127
127
  | `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
128
128
  | `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
129
129
  | `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
130
+ | `useRoomFree({ room, start, end })` | bookings: is a requestable room free (no names) |
130
131
  | `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
131
132
  | `usePushRegistration({ topics })` / `usePushTopics()` / `useNewPushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications); topic names come in the app's language |
132
133
  | `useAnnouncements({ kind, pinned })` / `useAnnouncement(id)` / `useAnnouncementState()` | announcements up right now; unread and closed banners kept on the phone (a dot, never a number); idle while `features.announcements` is false |
package/docs/server.md CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  getRepo, currentUser,
13
13
  listEvents, getEvent, listOccurrences,
14
14
  cancelOccurrence, restoreOccurrence,
15
+ eventRoomClashes, roomBusy,
15
16
  listMyEvents, listEventRequests, submitEventRequest, updateEventRequest,
16
17
  withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
17
18
  } from '@acms-subir/core/server';
@@ -32,6 +33,8 @@ getEvent(repo: Repo, id: string): Promise<CalEvent | null>
32
33
  listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number; tag?: string; excludeTag?: string }): Promise<Occurrence[]>
33
34
  cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
34
35
  restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
36
+ eventRoomClashes(repo: Repo, event: string | CalEvent, opts?: { horizonDays?: number; now?: Date | number }): Promise<EventClash[]>
37
+ roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<BusySlot[]>
35
38
 
36
39
  // event requests: `who` is currentUser()'s result (anything with `id` and `can()` works)
37
40
  listMyEvents(repo: Repo, who: ServerUser | null): Promise<CalEvent[]>
@@ -45,12 +48,16 @@ setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurre
45
48
 
46
49
  type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
47
50
  interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
48
- interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean }
51
+ interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean; roomIds: string[] }
52
+ interface Hold { resourceId: string; eventId: string; key: string; title: string; start: number; end: number; eventStart: number; eventEnd: number }
53
+ interface EventClash { hold: Hold; other: Hold }
54
+ interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
49
55
  interface WriteContext { url: URL; locals: unknown }
50
56
  ```
51
57
 
52
58
  The types `Repo`, `Role`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOptions`,
53
- `OccurrenceWindow`, `WriteContext` and `EventRequestInput` are exported too.
59
+ `OccurrenceWindow`, `WriteContext`, `EventRequestInput`, `Hold`, `EventClash` and
60
+ `BusySlot` are exported too.
54
61
 
55
62
  - **`getRepo(locals)`**: the site's content store for this request. Pass `Astro.locals`
56
63
  (or the endpoint/action's `locals`). `Repo` is an opaque handle; pass it to the other
@@ -121,6 +128,44 @@ another day (the admin's drag-to-move), its cancelled dates move with it. If its
121
128
  or rule is edited instead, keys that no longer match an occurrence are ignored, not an
122
129
  error.
123
130
 
131
+ ## Rooms
132
+
133
+ Events hold rooms (`event.roomHold.resourceIds`, the Rooms box in the event editor)
134
+ while they happen, plus any setup and cleanup minutes, or all day. Room bookings
135
+ already can't take a held room. Two events, though, may hold the same room at once.
136
+ These two functions find where things want the same room. Both include names, so
137
+ show their results to staff only, and neither checks permissions.
138
+
139
+ - **`eventRoomClashes(repo, event, opts?)`**: other events holding the same rooms as
140
+ this one at overlapping times, soonest first. Pass an event id, or a `CalEvent` with
141
+ unsaved values, which is checked as if it were published. The other events count
142
+ while they hold rooms: when they're published or scheduled, or drafts with **Hold
143
+ now** on, and not on a cancelled date. Each clash pairs one of this event's dates
144
+ (`hold`) with the other event's date (`other`). `hold.start`/`hold.end` include
145
+ setup and cleanup, and `eventStart`/`eventEnd` are the date's own times. It looks a
146
+ year ahead (`horizonDays`). The event editor shows the same check as a warning; both
147
+ events keep the room.
148
+ - **`roomBusy(repo, { roomIds, from, to, ignore? })`**: everything that takes these
149
+ rooms in `[from, to)`, soonest first. That covers events holding them, pending and
150
+ confirmed bookings, staff blocks, and the rooms' busy calendars. `kind` says which,
151
+ and `title` is the event's title, the booker's name or the block's note (`''` for a
152
+ busy calendar). `ignore: { eventId, key }` leaves out one date of an event, such as
153
+ the date you're about to move, and `ignore: { eventId }` leaves out the whole event.
154
+ An empty list means every room is free. Windows are at most 400 days.
155
+
156
+ ```ts
157
+ // is the hall free next Wednesday 5–6 PM for this one class date?
158
+ const busy = await roomBusy(repo, {
159
+ roomIds: [hall.id], from: new Date('2030-01-09T22:00:00Z'), to: new Date('2030-01-09T23:00:00Z'),
160
+ ignore: { eventId: classEvent.id, key: '2030-01-10T18:00' },
161
+ });
162
+ if (busy.length) return `Taken by ${busy[0].title || 'something else'}`;
163
+ ```
164
+
165
+ For people outside the staff, `GET /api/v1/rooms/free?room=<slug>&start=&end=` answers
166
+ only free or busy (with the busy times), never what or who. It works only for rooms
167
+ marked **Anyone can check if it’s free**.
168
+
124
169
  ## Private event fields
125
170
 
126
171
  A custom field can be private:
package/integration.mjs CHANGED
@@ -874,6 +874,8 @@ export default function acms(options = {}) {
874
874
  ['/embed/book/[resource]/[link]', 'embed/book/[resource]/[link].astro'],
875
875
  ['/api/v1/resources', 'api/v1/resources.ts'],
876
876
  ['/api/v1/availability', 'api/v1/availability.ts'],
877
+ // free or busy for a requestable room (no names)
878
+ ['/api/v1/rooms/free', 'api/v1/rooms/free.ts'],
877
879
  ['/api/calendar/availability', 'api/calendar/availability.ts'],
878
880
  ['/api/calendar/bookings', 'api/calendar/bookings/index.ts'],
879
881
  ['/api/calendar/bookings/[id]', 'api/calendar/bookings/[id].ts'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.44",
3
+ "version": "0.3.46",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -22,7 +22,7 @@ import { getCalendarEvents } from '../lib/calendar/ics';
22
22
  import { siteLocale } from '../lib/dates';
23
23
  import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
24
24
  import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
25
- import { holdConflicts } from '../lib/booking/holds';
25
+ import { eventClashes, holdConflicts } from '../lib/booking/holds';
26
26
  import { setBookingStatus } from '../lib/booking/service';
27
27
  import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes } from '../lib/calendar/events';
28
28
  import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../lib/calendar/import';
@@ -32,7 +32,6 @@ import { collectViewEvents } from '../lib/calendar/data';
32
32
  import { loadBoard } from '../lib/calendar/board';
33
33
  import { cancelOccurrence, duplicateEvent, moveOccurrence, moveSeries, quickCreate, restoreOccurrence, undoMove } from '../lib/calendar/schedule';
34
34
  import { waitUntilFrom } from '../lib/webhooks';
35
- import { eventChanged, eventDeleted } from '../lib/calendar/hooks';
36
35
  import { siteOrigin } from '../lib/seo';
37
36
  import type { CalEvent } from '../lib/schema';
38
37
  import { CalEvent as CalEventSchema } from '../lib/schema';
@@ -40,7 +39,7 @@ import { diffDesign, revertDesignChange, summarizeChanges } from '../lib/designD
40
39
  import { shareEpoch } from '../lib/drafts';
41
40
  import { createDesignToken } from '../lib/preview';
42
41
  import { getTheme } from '../lib/theme';
43
- import { savePost, applyPostUpdate, savePage, applyPageUpdate, saveEvent, applyEventUpdate, type SaveHooks } from '../lib/content-service';
42
+ import { savePost, applyPostUpdate, savePage, applyPageUpdate, saveEvent, applyEventUpdate, flushEventHooks, type SaveHooks } from '../lib/content-service';
44
43
  import { resolveMapLink, searchPlaces } from '../lib/maps/service';
45
44
  import { savePlace } from '../lib/maps/places';
46
45
 
@@ -1027,15 +1026,15 @@ export const server = {
1027
1026
  handler: async ({ id }, ctx) => {
1028
1027
  await requireCan(ctx, 'content.delete');
1029
1028
  const repo = await getRepo(ctx.locals);
1030
- const before = await repo.getEvent(id);
1031
1029
  await repo.deleteEvent(id);
1032
- await eventDeleted(repo, before, { waitUntil: waitUntilFrom(ctx.locals) });
1030
+ await flushEventHooks(repo, hooksFor(ctx));
1033
1031
  return { ok: true };
1034
1032
  },
1035
1033
  }),
1036
1034
  // Event editor → Rooms: which rooms/people are free at this event's time
1037
- // (for every room, so the picker can label them), and which existing
1038
- // bookings the selected rooms would overlap. Works on unsaved values.
1035
+ // (for every room, so the picker can label them), which existing bookings
1036
+ // the selected rooms would overlap, and which other events hold them then
1037
+ // (a warning: both keep the room). Works on unsaved values.
1039
1038
  roomStatus: defineAction({
1040
1039
  input: z.object({
1041
1040
  eventId: z.string().default(''),
@@ -1064,7 +1063,7 @@ export const server = {
1064
1063
  timezone: input.timezone, rrule: input.rrule, exdates: input.exdates, status: 'published', startAt: 0, endAt: 0, createdAt: 0, updatedAt: 0,
1065
1064
  roomHold: { ...input.roomHold, resourceIds: resources.map((r) => r.id), keep: input.roomHold.keep },
1066
1065
  });
1067
- const { conflicts, holds } = await holdConflicts(repo, settings, probe, { now });
1066
+ const [{ conflicts, holds }, { clashes }] = await Promise.all([holdConflicts(repo, settings, probe, { now }), eventClashes(repo, settings, probe, { now })]);
1068
1067
  const tz = settings.timezone || 'UTC';
1069
1068
  const locale = siteLocale(settings);
1070
1069
  const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
@@ -1077,9 +1076,18 @@ export const server = {
1077
1076
  holds: holdsLabel,
1078
1077
  rooms: resources.map((r) => {
1079
1078
  const mine = conflicts.filter((c) => c.booking.resourceId === r.id);
1079
+ // one row per other event's date, however many of ours it overlaps
1080
+ const others = [...new Map(clashes.filter((c) => c.other.resourceId === r.id).map((c) => [`${c.other.eventId}:${c.other.eventStart}`, c])).values()];
1080
1081
  return {
1081
1082
  id: r.id, name: r.name, kind: r.kind, capacity: r.capacity,
1082
1083
  datesWithBookings: new Set(mine.map((c) => c.hold.eventStart)).size, dates: occurrences,
1084
+ datesWithEvents: new Set(others.map((c) => c.hold.eventStart)).size,
1085
+ datesBusy: new Set([...mine.map((c) => c.hold.eventStart), ...others.map((c) => c.hold.eventStart)]).size,
1086
+ events: others.slice(0, 20).map((c) => ({
1087
+ id: `${c.other.eventId}:${c.other.eventStart}`, eventId: c.other.eventId, title: c.other.title,
1088
+ when: `${day.format(c.other.eventStart)} · ${range(c.other.eventStart, c.other.eventEnd)}`,
1089
+ })),
1090
+ eventCount: others.length,
1083
1091
  conflicts: mine.slice(0, 20).map((c) => ({
1084
1092
  id: c.booking.id, name: c.booking.kind === 'block' ? `Blocked${c.booking.notes ? ` · ${c.booking.notes}` : ''}` : c.booking.name,
1085
1093
  block: c.booking.kind === 'block', status: c.booking.status,
@@ -98,7 +98,7 @@ export default function QuickAdd(props: Props) {
98
98
  const s = allDay ? day : `${day}T${from}`;
99
99
  const e = allDay ? (endDay < day ? day : endDay) : `${endDay < day ? day : endDay}T${to}`;
100
100
  fetchRoomStatus({ eventId: '', title, start: s, end: e, allDay, timezone: '', rrule: repeatRule(repeat, day), exdates: [] }, NO_HOLD)
101
- .then((d) => setRooms((d?.rooms ?? []).map((r) => ({ id: r.id, name: r.name, busy: r.conflicts.length }))))
101
+ .then((d) => setRooms((d?.rooms ?? []).map((r) => ({ id: r.id, name: r.name, busy: r.conflicts.length + r.eventCount }))))
102
102
  .catch(() => {});
103
103
  }, 350);
104
104
  return () => clearTimeout(t);