@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.
- package/CHANGELOG.md +40 -0
- package/cli/main.mjs +3 -2
- package/cli/migrations.generated.mjs +128 -16
- package/docs/calendar.md +112 -0
- package/docs/environment-sync.md +6 -0
- package/docs/mobile.md +1 -0
- package/docs/server.md +47 -2
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/actions/index.ts +16 -8
- package/src/components/calendar/QuickAdd.tsx +1 -1
- package/src/components/calendar/RoomHolds.tsx +23 -5
- package/src/lib/booking/holds.ts +98 -2
- package/src/lib/booking/service.ts +2 -1
- package/src/lib/calendar/agent.ts +5 -5
- package/src/lib/calendar/api.ts +16 -3
- package/src/lib/calendar/change-migration.ts +65 -0
- package/src/lib/calendar/data.ts +12 -1
- package/src/lib/calendar/events.ts +4 -2
- package/src/lib/calendar/hooks.ts +76 -31
- package/src/lib/calendar/requests.ts +2 -3
- package/src/lib/calendar/schedule.ts +4 -4
- package/src/lib/content-service.ts +15 -12
- package/src/lib/editor/schedule.ts +3 -0
- package/src/lib/mobile/calendar.ts +49 -11
- package/src/lib/mobile/openapi.ts +3 -1
- package/src/lib/mobile/schemas.ts +8 -3
- package/src/lib/mobile/search.ts +5 -6
- package/src/lib/push/service.ts +1 -1
- package/src/lib/repo/migrations.ts +15 -0
- package/src/lib/repo/sql.ts +36 -5
- package/src/lib/repo/types.ts +21 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +10 -0
- package/src/lib/sync/catalog.ts +2 -0
- package/src/lib/sync/store.ts +4 -0
- package/src/middleware.ts +13 -2
- package/src/pages/admin/calendar/events/index.astro +4 -9
- package/src/pages/admin/calendar/resources/[id].astro +13 -1
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +5 -5
- package/src/pages/api/calendar/events/index.ts +7 -4
- package/src/pages/api/v1/rooms/free.ts +7 -0
- package/src/pages/api/webhooks/docs.ts +5 -0
- package/src/server/index.ts +37 -0
- package/src/styles/admin.css +3 -0
- 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
|
|
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
|
|
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}',${
|
|
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 (${
|
|
98
|
+
`(OLD.status='published' OR NEW.status='published') AND (${changed2})`,
|
|
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 ${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}',${
|
|
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
|
|
472
|
-
const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${
|
|
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
|
|
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 =
|
|
1287
|
+
const deadline = now5() + (opts.waitMs ?? 6e4);
|
|
1176
1288
|
let waited = false;
|
|
1177
1289
|
for (; ; ) {
|
|
1178
|
-
const t =
|
|
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, ?)", [
|
|
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,
|
|
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) };
|
package/docs/calendar.md
ADDED
|
@@ -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)`.
|
package/docs/environment-sync.md
CHANGED
|
@@ -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`
|
|
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
package/src/actions/index.ts
CHANGED
|
@@ -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
|
|
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),
|
|
1038
|
-
//
|
|
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);
|