@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
@@ -5,7 +5,8 @@ import { actions } from 'astro:actions';
5
5
  // they can't be booked while it happens (± setup/cleanup) or all day. Each
6
6
  // room shows whether it's free at the event's time; overlapping bookings show
7
7
  // right here, with Keep (share the room) or Cancel and email. The same check
8
- // runs again when you publish (RoomConflictDialog).
8
+ // runs again when you publish (RoomConflictDialog). Other events holding the
9
+ // room at the same time show as a warning: both keep it.
9
10
 
10
11
  export interface RoomHoldValue {
11
12
  resourceIds: string[];
@@ -28,7 +29,12 @@ export interface RoomTimes {
28
29
  }
29
30
 
30
31
  interface Conflict { id: string; name: string; when: string; status: string; block: boolean }
31
- interface RoomRow { id: string; name: string; kind: string; capacity: number | null; conflicts: Conflict[]; datesWithBookings: number; dates: number }
32
+ interface EventClash { id: string; eventId: string; title: string; when: string }
33
+ interface RoomRow {
34
+ id: string; name: string; kind: string; capacity: number | null; conflicts: Conflict[]; datesWithBookings: number; dates: number;
35
+ // other events holding it at the same time (eventCount: all of them, events: the first 20)
36
+ events: EventClash[]; eventCount: number; datesWithEvents: number; datesBusy: number;
37
+ }
32
38
  interface Status { rooms: RoomRow[]; holds: string }
33
39
 
34
40
  export const NO_HOLD: RoomHoldValue = { resourceIds: [], span: 'event', before: 0, after: 0, whileDraft: false, keep: [] };
@@ -77,11 +83,13 @@ export default function RoomHolds({ times, hold, status, locationName, onChange,
77
83
  if (auto) onLocation(names(ids));
78
84
  };
79
85
  const label = (r: RoomRow) => {
80
- if (!r.conflicts.length) return { text: 'Free', cls: 'free' };
81
- if (r.dates > 1) return { text: `Busy on ${r.datesWithBookings} of ${r.dates} dates`, cls: 'busy' };
82
- return { text: r.conflicts.length === 1 ? 'Busy' : `Busy · ${r.conflicts.length} bookings`, cls: 'busy' };
86
+ if (!r.conflicts.length && !r.eventCount) return { text: 'Free', cls: 'free' };
87
+ if (r.dates > 1) return { text: `Busy on ${r.datesBusy} of ${r.dates} dates`, cls: 'busy' };
88
+ if (!r.conflicts.length) return { text: r.eventCount === 1 ? 'Another event' : `${r.eventCount} other events`, cls: 'busy' };
89
+ return { text: r.conflicts.length === 1 && !r.eventCount ? 'Busy' : `Busy · ${r.conflicts.length + r.eventCount} bookings and events`, cls: 'busy' };
83
90
  };
84
91
  const conflicted = data.rooms.filter((r) => selected.has(r.id) && r.conflicts.length);
92
+ const shared = data.rooms.filter((r) => selected.has(r.id) && r.eventCount);
85
93
  const draft = status === 'draft';
86
94
 
87
95
  async function cancelBookings(ids: string[]) {
@@ -157,6 +165,16 @@ export default function RoomHolds({ times, hold, status, locationName, onChange,
157
165
  )}
158
166
  </div>
159
167
  ))}
168
+ {shared.map((r) => (
169
+ <div key={r.id} className="rh-conflict rh-clash" role="status">
170
+ <b>{r.name} is also held by {r.eventCount === 1 ? 'another event' : `other events on ${r.eventCount} dates`}</b>
171
+ {r.events.slice(0, 5).map((c) => (
172
+ <div key={c.id} className="rh-bk"><a href={`/admin/calendar/events/${c.eventId}`} target="_blank" rel="noopener">{c.title}</a><small>{c.when}</small></div>
173
+ ))}
174
+ {r.eventCount > 5 && <small className="rh-more">+{r.eventCount - 5} more</small>}
175
+ <small className="rh-more">Both events keep the room. Change a time or pick another room if they can’t share it.</small>
176
+ </div>
177
+ ))}
160
178
  {note && <p className="pe-ev-note rh-done">{note}</p>}
161
179
  {data.holds && <p className="pe-ev-note">{data.holds}{draft && !hold.whileDraft ? ' once published.' : '.'}</p>}
162
180
  </>
@@ -4,6 +4,8 @@
4
4
  // moving, repeating, skipping a date, cancelling the event or one of its
5
5
  // dates, or deleting it frees or moves the room automatically. availabilityContext() adds these to its
6
6
  // busy list, so every booking path (pages, embeds, app, API, agent) obeys.
7
+ // Two events may hold the same room at once: eventClashes() finds those for
8
+ // staff, and roomBusy() answers "is this room taken then?" for one span.
7
9
  import type { ContentRepository } from '../repo/types';
8
10
  import type { Booking, CalEvent, Resource, Settings } from '../schema';
9
11
  import { expandEvents } from '../calendar/events';
@@ -13,6 +15,8 @@ import { resourceZone } from './availability';
13
15
  export interface Hold {
14
16
  resourceId: string;
15
17
  eventId: string;
18
+ // the occurrence key of the date that holds it (lib/calendar/events.ts)
19
+ key: string;
16
20
  title: string;
17
21
  start: number; // incl. setup
18
22
  end: number; // incl. cleanup
@@ -58,7 +62,8 @@ export function holdsFromEvents(
58
62
  // a cancelled date frees the room for that slot only
59
63
  if (o.cancelled) continue;
60
64
  const h = o.event.roomHold;
61
- for (const resourceId of h.resourceIds) {
65
+ // the rooms this date holds
66
+ for (const resourceId of o.roomIds) {
62
67
  if (opts.resourceId && resourceId !== opts.resourceId) continue;
63
68
  const r = resources.get(resourceId);
64
69
  if (!r) continue;
@@ -73,7 +78,7 @@ export function holdsFromEvents(
73
78
  end = o.end + h.after * 60_000;
74
79
  }
75
80
  if (end <= from || start >= to) continue;
76
- out.push({ resourceId, eventId: o.event.id, title: o.event.title || 'Event', start, end, eventStart: o.start, eventEnd: o.end });
81
+ out.push({ resourceId, eventId: o.event.id, key: o.key, title: o.event.title || 'Event', start, end, eventStart: o.start, eventEnd: o.end });
77
82
  }
78
83
  }
79
84
  return out.sort((a, b) => a.start - b.start);
@@ -120,3 +125,94 @@ export async function holdConflicts(
120
125
  }
121
126
  return { conflicts: conflicts.sort((a, b) => a.booking.start - b.booking.start), holds };
122
127
  }
128
+
129
+ // ---- events against events ------------------------------------------------
130
+
131
+ export interface EventClash {
132
+ /** one date of the event being checked, in one room */
133
+ hold: Hold;
134
+ /** another event's date holding that room at an overlapping time */
135
+ other: Hold;
136
+ }
137
+
138
+ /**
139
+ * Other events holding the same rooms at overlapping times (setup and cleanup
140
+ * included), soonest first. Like holdConflicts, `event` may be unsaved and is
141
+ * checked as if published; the others count only while they hold rooms
142
+ * (published or scheduled, drafts with "Hold now", not cancelled). These are
143
+ * warnings for staff: both events keep the room.
144
+ */
145
+ export async function eventClashes(
146
+ repo: ContentRepository,
147
+ settings: Pick<Settings, 'timezone'>,
148
+ event: CalEvent,
149
+ opts: { now?: number; horizonDays?: number } = {}
150
+ ): Promise<{ clashes: EventClash[]; holds: Hold[] }> {
151
+ const now = opts.now ?? Date.now();
152
+ const to = now + (opts.horizonDays ?? 366) * DAY;
153
+ const [events, resources] = await Promise.all([repo.listEvents(), repo.listResources()]);
154
+ const byId = new Map(resources.map((r) => [r.id, r]));
155
+ const holds = holdsFromEvents([event], byId, settings, now, to, { force: true });
156
+ if (!holds.length) return { clashes: [], holds };
157
+ const rooms = new Set(holds.map((h) => h.resourceId));
158
+ const others = events.filter((e) => e.id !== event.id && e.roomHold.resourceIds.some((id) => rooms.has(id)));
159
+ const from = Math.min(...holds.map((h) => h.start)), until = Math.max(...holds.map((h) => h.end));
160
+ const theirs = holdsFromEvents(others, byId, settings, from, until);
161
+ const clashes: EventClash[] = [];
162
+ for (const hold of holds) {
163
+ for (const other of theirs) {
164
+ if (other.resourceId === hold.resourceId && other.start < hold.end && other.end > hold.start) clashes.push({ hold, other });
165
+ }
166
+ }
167
+ return { clashes, holds };
168
+ }
169
+
170
+ export interface BusySlot {
171
+ resourceId: string;
172
+ start: number;
173
+ end: number;
174
+ /** an event holding it, a booking, a staff block, or one of its busy calendars */
175
+ kind: 'event' | 'booking' | 'block' | 'calendar';
176
+ /** the event's title, the booker's name or the block's note; '' for busy calendars. Staff only. */
177
+ title: string;
178
+ eventId?: string;
179
+ /** the event date's occurrence key */
180
+ key?: string;
181
+ bookingId?: string;
182
+ }
183
+
184
+ /**
185
+ * Everything that takes these rooms in [from, to): event holds (setup and
186
+ * cleanup included), pending and confirmed bookings and blocks, and the rooms'
187
+ * busy calendars. `ignore` leaves out one event, or one date of it (its key):
188
+ * the date being moved doesn't block its own new time. Names are for staff;
189
+ * the public free/busy check (GET /api/v1/rooms/free) only reports the times.
190
+ */
191
+ export async function roomBusy(
192
+ repo: ContentRepository,
193
+ settings: Pick<Settings, 'timezone'>,
194
+ q: { resourceIds: string[]; from: number; to: number; ignore?: { eventId: string; key?: string }; now?: number }
195
+ ): Promise<BusySlot[]> {
196
+ const ids = new Set(q.resourceIds);
197
+ if (!ids.size || q.to <= q.from) return [];
198
+ const [events, resources] = await Promise.all([repo.listEvents(), repo.listResources()]);
199
+ const rooms = resources.filter((r) => ids.has(r.id));
200
+ const byId = new Map(rooms.map((r) => [r.id, r]));
201
+ const out: BusySlot[] = [];
202
+ const holding = events.filter((e) => e.roomHold.resourceIds.some((id) => ids.has(id)) && !(q.ignore && e.id === q.ignore.eventId && !q.ignore.key));
203
+ for (const h of holdsFromEvents(holding, byId, settings, q.from, q.to)) {
204
+ if (q.ignore?.key && h.eventId === q.ignore.eventId && h.key === q.ignore.key) continue;
205
+ out.push({ resourceId: h.resourceId, start: h.start, end: h.end, kind: 'event', title: h.title, eventId: h.eventId, key: h.key });
206
+ }
207
+ // dynamic: service.ts imports this module
208
+ const { busyIntervals } = await import('./service');
209
+ await Promise.all(rooms.map(async (r) => {
210
+ const bookings = await repo.listBookings({ resourceId: r.id, from: q.from, to: q.to, statuses: ['pending', 'confirmed'], kinds: ['booking', 'block'] });
211
+ // a booking's real time, as holdConflicts compares it
212
+ for (const b of bookings) out.push({ resourceId: r.id, start: b.start, end: b.end, kind: b.kind === 'block' ? 'block' : 'booking', title: b.kind === 'block' ? b.notes : b.name, bookingId: b.id });
213
+ for (const i of await busyIntervals(r, q.from, q.to, q.now ?? Date.now())) {
214
+ if (i.end > q.from && i.start < q.to) out.push({ resourceId: r.id, start: i.start, end: i.end, kind: 'calendar', title: '' });
215
+ }
216
+ }));
217
+ return out.sort((a, b) => a.start - b.start || a.end - b.end);
218
+ }
@@ -50,7 +50,8 @@ export const WAITLIST_HOLD = 24 * 3_600_000;
50
50
 
51
51
  const busyCache = new Map<string, { at: number; intervals: Interval[] }>();
52
52
 
53
- async function busyIntervals(resource: Resource, from: number, to: number, now: number): Promise<Interval[]> {
53
+ /** a resource's busy calendars (Resource.busyFeeds) in [from, to), cached for 5 minutes */
54
+ export async function busyIntervals(resource: Resource, from: number, to: number, now: number): Promise<Interval[]> {
54
55
  if (!resource.busyFeeds.length) return [];
55
56
  const key = `${resource.id}:${resource.busyFeeds.join('|')}:${Math.floor(from / 86_400_000)}`;
56
57
  const hit = busyCache.get(key);
@@ -11,7 +11,7 @@ import { CALENDAR } from './config';
11
11
  import { EventToolInputs } from './agent-schema';
12
12
  import { featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes } from './events';
13
13
  import { mergeEventDraft } from '../drafts';
14
- import { eventChanged } from './hooks';
14
+ import { flushEventChanges } from './hooks';
15
15
  import { eventToJson } from './api';
16
16
  import { siteLocale } from '../dates';
17
17
  import { EventLocation } from '../schema';
@@ -110,7 +110,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
110
110
  case 'get_event': {
111
111
  const e = await repo.getEvent(input.arguments.id);
112
112
  if (!e) throw new ApiError(404, 'Event not found.');
113
- return { ...eventToJson(mergeEventDraft(e), ctx.origin, calMap, settings.timezone, { includePrivate: true }), bodyHtml: mergeEventDraft(e).bodyHtml, hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id) };
113
+ const rooms = new Map((await repo.listResources()).map((r) => [r.id, r]));
114
+ return { ...eventToJson(mergeEventDraft(e), ctx.origin, calMap, settings.timezone, { includePrivate: true, resources: rooms }), bodyHtml: mergeEventDraft(e).bodyHtml, hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id) };
114
115
  }
115
116
  case 'create_event': {
116
117
  const a = input.arguments;
@@ -168,9 +169,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
168
169
  if (!before) throw new ApiError(404, 'Event not found.');
169
170
  if (before.draft) await repo.applyEventDraft(before.id);
170
171
  await repo.updateEvent(before.id, { status: 'published', scheduledAt: null });
171
- const after = (await repo.getEvent(before.id))!;
172
- await eventChanged(repo, before, after, { origin: ctx.origin, waitUntil: ctx.waitUntil, applied: Boolean(before.draft) });
173
- return { ...summary(after) };
172
+ await flushEventChanges(repo, { origin: ctx.origin, waitUntil: ctx.waitUntil });
173
+ return { ...summary((await repo.getEvent(before.id))!) };
174
174
  }
175
175
  }
176
176
  }
@@ -2,8 +2,8 @@
2
2
  // webhook payloads and agent tools, so integrations see the same fields
3
3
  // everywhere. Drafts, internal ids of private calendars, staging data and
4
4
  // private custom fields (unless a staff caller asks for them) never appear
5
- // here.
6
- import type { CalEvent, Calendar } from '../schema';
5
+ // here. Rooms are named only when set to show on public events (or for staff).
6
+ import { roomColor, type CalEvent, type Calendar, type Resource } from '../schema';
7
7
  import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
8
8
  import { describeRepeat } from './rrule';
9
9
 
@@ -33,13 +33,25 @@ export interface EventJson {
33
33
  imageAlt: string;
34
34
  tags: string[];
35
35
  calendars: { id: string; slug: string; name: string; color: string }[];
36
+ // the rooms it holds (roomHold.resourceIds); color null = the room has none
37
+ rooms: RoomJson[];
36
38
  fields: Record<string, string>;
37
39
  updatedAt: string;
38
40
  }
39
41
 
42
+ export interface RoomJson { slug: string; name: string; color: string | null }
43
+
44
+ /** Rooms as public data: only those set to show on public events, unless `includePrivate` (staff). Unknown ids are skipped. */
45
+ export function roomsToJson(ids: string[], resources: Map<string, Resource> | undefined, includePrivate = false): RoomJson[] {
46
+ if (!resources) return [];
47
+ return ids.map((id) => resources.get(id)).filter((r): r is Resource => Boolean(r) && (includePrivate || r!.showOnEvents))
48
+ .map((r) => ({ slug: r.slug, name: r.name, color: roomColor(r) || null }));
49
+ }
50
+
40
51
  const abs = (origin: string, path: string) => (/^https?:\/\//.test(path) ? path : origin + path);
41
52
 
42
- export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean } = {}): EventJson {
53
+ // resources: every room by id (rooms stay [] without it)
54
+ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean; resources?: Map<string, Resource> } = {}): EventJson {
43
55
  return {
44
56
  id: e.id,
45
57
  slug: e.slug,
@@ -68,6 +80,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
68
80
  .map((id) => calendars.get(id))
69
81
  .filter((c): c is Calendar => Boolean(c) && c!.visibility !== 'private')
70
82
  .map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
83
+ rooms: roomsToJson(e.roomHold.resourceIds, opts.resources, opts.includePrivate),
71
84
  fields: opts.includePrivate ? e.fields : publicEventFields(e.fields),
72
85
  updatedAt: new Date(e.updatedAt).toISOString(),
73
86
  };
@@ -0,0 +1,65 @@
1
+ // v65: every write that touches a published event (editor, Schedule, agent,
2
+ // REST, scheduled publishing, place refresh, environment sync, any raw SQL)
3
+ // leaves a row in event_changes, written by triggers in the same transaction:
4
+ // the event as it was and as written. lib/calendar/hooks.ts drains it into the
5
+ // side effects (webhook, "event changed" push, slug redirect), so no caller
6
+ // has to remember to. Its own table, not a shared "last change" column, so
7
+ // other features can keep their own queues from their own triggers on events.
8
+ //
9
+ // Only changes a visitor can see are recorded (CHANGED): hosts, members'
10
+ // requests, staged drafts and bookkeeping never are. Restores run
11
+ // with webhook_control.suppressed=1 and record nothing. Environment sync sets
12
+ // push_control.quiet=1 for its transaction: its changes still send webhooks,
13
+ // but never member pushes (its first publishes are recorded as skipped, like
14
+ // restores). Posts keep their v37 triggers.
15
+ //
16
+ // Frozen with v65: a later events column joins by a new migration that
17
+ // recreates these triggers (the test that sorts every column fails until then).
18
+ const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
19
+
20
+ /** the columns copied into each snapshot (what the webhook payload and push rules read) */
21
+ export const EVENT_CHANGE_SNAPSHOT = ['id', 'slug', 'title', 'excerpt', 'cover', 'cover_alt', 'status', 'featured', 'tags',
22
+ 'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'cancelled_dates', 'location', 'cta', 'calendar_ids',
23
+ 'fields', 'rsvp', 'room_hold', 'start_at', 'end_at', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
24
+ /** the columns whose change counts (published_at and scheduled_at follow status; room_hold names the rooms public data shows) */
25
+ export const EVENT_CHANGE_COLUMNS = ['slug', 'title', 'body_json', 'body_html', 'excerpt', 'cover', 'cover_alt', 'seo_title', 'seo_desc',
26
+ 'status', 'featured', 'tags', 'template', 'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'cancelled_dates',
27
+ 'location', 'cta', 'calendar_ids', 'fields', 'rsvp', 'translations', 'room_hold', 'start_at', 'end_at'];
28
+ /** columns no change is recorded for */
29
+ export const EVENT_CHANGE_IGNORED = ['id', 'draft', 'host_ids', 'request', 'source_uid', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
30
+
31
+ const snapshot = (row: 'OLD' | 'NEW') => `json_object(${EVENT_CHANGE_SNAPSHOT.map((c) => `'${c}',${row}.${c}`).join(',')})`;
32
+ const live = '(SELECT suppressed FROM webhook_control WHERE id=1)=0';
33
+ const quiet = 'COALESCE((SELECT quiet FROM push_control WHERE id=1),0)';
34
+ const record = (id: string, before: string, after: string) => `INSERT INTO event_changes (event_id,old_row,new_row,quiet,created_at)
35
+ VALUES (${id},${before},${after},${quiet},${now});`;
36
+ const changed = EVENT_CHANGE_COLUMNS.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(' OR ');
37
+
38
+ // v37's first-publish ledger rows, now also skipped while push_control.quiet is set
39
+ const skipped = `CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 OR ${quiet}=1 THEN 'skipped' ELSE 'pending' END`;
40
+ const ledger = `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
41
+ SELECT 'event:'||NEW.id,'event',NEW.id,NULL,${skipped},NULL,${now},${now}
42
+ WHERE NOT EXISTS (SELECT 1 FROM push_queue WHERE key='event:'||NEW.id);`;
43
+
44
+ export const EVENT_CHANGES_MIGRATION = { version: 65, statements: [
45
+ `CREATE TABLE event_changes (
46
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL, old_row TEXT, new_row TEXT,
47
+ quiet INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, claimed_until INTEGER NOT NULL DEFAULT 0,
48
+ created_at INTEGER NOT NULL
49
+ )`,
50
+ `CREATE INDEX event_changes_event ON event_changes (event_id, claimed_until)`,
51
+ `CREATE TABLE push_control (id INTEGER PRIMARY KEY CHECK(id=1), quiet INTEGER NOT NULL DEFAULT 0)`,
52
+ `INSERT INTO push_control (id,quiet) VALUES (1,0)`,
53
+ `CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
54
+ BEGIN ${record('NEW.id', 'NULL', snapshot('NEW'))} END`,
55
+ `CREATE TRIGGER event_changes_update AFTER UPDATE ON events
56
+ WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
57
+ BEGIN ${record('NEW.id', snapshot('OLD'), snapshot('NEW'))} END`,
58
+ `CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
59
+ BEGIN ${record('OLD.id', snapshot('OLD'), 'NULL')} END`,
60
+ `DROP TRIGGER push_event_insert`,
61
+ `DROP TRIGGER push_event_publish`,
62
+ `CREATE TRIGGER push_event_insert AFTER INSERT ON events WHEN NEW.status='published' BEGIN ${ledger} END`,
63
+ `CREATE TRIGGER push_event_publish AFTER UPDATE OF status ON events
64
+ WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${ledger} END`,
65
+ ] };
@@ -3,7 +3,7 @@
3
3
  // agenda. Native events are expanded from the DB; feed calendars are fetched
4
4
  // live through the cached ICS loader. Visibility rules live in events.ts.
5
5
  import type { ContentRepository } from '../repo/types';
6
- import type { CalEvent, Calendar, Settings } from '../schema';
6
+ import { roomColor, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
7
7
  import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
8
8
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
9
9
  import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
@@ -16,8 +16,14 @@ export interface ViewEvent extends CalendarEvent {
16
16
  imageAlt?: string;
17
17
  calendarName?: string;
18
18
  event?: CalEvent;
19
+ // native events: the rooms this date holds; public views name only rooms
20
+ // set to show on public events (Resource.showOnEvents)
21
+ rooms?: ViewRoom[];
19
22
  }
20
23
 
24
+ // color: '#rrggbb', or '' when the room has none
25
+ export interface ViewRoom { id: string; slug: string; name: string; color: string }
26
+
21
27
  export interface ViewScope {
22
28
  audience: 'public' | 'admin';
23
29
  // limit to these calendars; omitted = every calendar the audience may see
@@ -70,6 +76,10 @@ export async function collectViewEvents(
70
76
  if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(e));
71
77
  return shown.get(e.id)!;
72
78
  };
79
+ const resources = natives.some((e) => e.roomHold.resourceIds.length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
80
+ const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
81
+ .filter((r): r is Resource => Boolean(r) && (scope.audience === 'admin' || r!.showOnEvents))
82
+ .map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
73
83
  for (const o of expandEvents(natives, settings, win)) {
74
84
  const e = o.event;
75
85
  const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
@@ -89,6 +99,7 @@ export async function collectViewEvents(
89
99
  imageAlt: e.coverAlt || undefined,
90
100
  calendarName: cal?.name,
91
101
  event: view(e),
102
+ rooms: rooms(o.roomIds),
92
103
  });
93
104
  }
94
105
 
@@ -122,6 +122,8 @@ export interface Occurrence {
122
122
  key: string;
123
123
  // the whole event is cancelled, or just this date
124
124
  cancelled: boolean;
125
+ // the rooms this date holds (Resource ids; the event's roomHold.resourceIds)
126
+ roomIds: string[];
125
127
  }
126
128
 
127
129
  /**
@@ -148,7 +150,7 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
148
150
  const cancelledDates = new Set(e.cancelledDates ?? []);
149
151
  for (const o of expandVEvents([v], tz, win, 0)) {
150
152
  const key = occurrenceKey(e, o.start, settings, win.displayTz);
151
- out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key) });
153
+ out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key), roomIds: e.roomHold?.resourceIds ?? [] });
152
154
  }
153
155
  }
154
156
  return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
@@ -196,7 +198,7 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
196
198
  const en = parseWall(e.end) ?? s;
197
199
  const days = e.allDay && s && en ? Math.round((wallMinutes(en) - wallMinutes(s)) / 1440) + 1 : 1;
198
200
  const key = occurrenceKey(e, startAt, settings, displayTz);
199
- return { event: e, start: startAt, end: endAt, allDay: e.allDay, days, key, cancelled: e.cancelled || (e.cancelledDates ?? []).includes(key) };
201
+ return { event: e, start: startAt, end: endAt, allDay: e.allDay, days, key, cancelled: e.cancelled || (e.cancelledDates ?? []).includes(key), roomIds: e.roomHold?.resourceIds ?? [] };
200
202
  }
201
203
  const next = expandEvents([e], settings, { displayTz, now, windowStartMs: now, windowEndMs: now + 3 * 366 * DAY })[0];
202
204
  if (next) return next;
@@ -1,42 +1,87 @@
1
- // Side effects when an event changes live: a 301 when a published event's
2
- // slug moves, a queued "event changed" app notice when its schedule or place
3
- // moves, and the matching webhook (event.published / event.updated /
4
- // event.unpublished). Shared by the editor actions, the list's bulk actions
5
- // and the agent tools so every path behaves the same.
6
- import type { ContentRepository } from '../repo/types';
7
- import type { CalEvent } from '../schema';
1
+ // Side effects when a published event changes: a 301 when its slug moves, a
2
+ // queued "event changed" app notice when its schedule or place moves, and the
3
+ // matching webhook (event.published / event.updated / event.unpublished /
4
+ // event.deleted). Every write path records its changes in event_changes
5
+ // (triggers, ./change-migration.ts) in the same transaction, so this is the
6
+ // one place they become side effects, whoever made them. Editor paths flush
7
+ // at once (the redirect works on the very next request); the flush after
8
+ // every mutating request and the scheduler catch the rest, raw SQL included
9
+ // (scheduled publishing, environment sync, place refreshes).
10
+ import type { ContentRepository, EventChange } from '../repo/types';
11
+ import type { Calendar, Resource, Settings } from '../schema';
8
12
  import { emitWebhook } from '../webhooks';
9
- import { queueEventChange } from '../push/service';
13
+ import { queueEventChange, siteOriginOf } from '../push/service';
10
14
  import { eventToJson } from './api';
11
15
  import { eventUrl } from './config';
12
16
 
13
- export async function eventChanged(
14
- repo: ContentRepository,
15
- before: CalEvent,
16
- after: CalEvent,
17
- opts: { origin: string; waitUntil?: (p: Promise<unknown>) => void; applied?: boolean; onRedirect?: () => void }
18
- ): Promise<void> {
19
- if (before.status === 'published' && after.status === 'published' && before.slug !== after.slug) {
17
+ export interface FlushOptions {
18
+ /** this request's origin, for webhook links while the site URL isn't set */
19
+ origin?: string;
20
+ waitUntil?: (p: Promise<unknown>) => void;
21
+ /** called when a slug redirect was added (the middleware drops its cached rules) */
22
+ onRedirect?: () => void;
23
+ now?: number;
24
+ }
25
+
26
+ const BATCH = 50;
27
+ const ROUNDS = 20;
28
+ /** how long a claim holds its changes; one that failed is retried after it */
29
+ const LEASE = 5 * 60_000;
30
+ /** a change that failed this many times is dropped */
31
+ const MAX_ATTEMPTS = 3;
32
+
33
+ interface Site { origin: string; settings: Settings; calendars: Map<string, Calendar>; resources: Map<string, Resource> }
34
+
35
+ // One write, as webhooks for posts report them: several writes in one save
36
+ // send one webhook each. The push queue keeps what people last knew, so
37
+ // several changes close together still send one notice.
38
+ async function apply(repo: ContentRepository, change: EventChange, now: number, site: () => Promise<Site>, opts: FlushOptions) {
39
+ const { before, after } = change;
40
+ const was = before?.status === 'published';
41
+ if (was && after?.status === 'published' && before.slug !== after.slug) {
20
42
  await repo.createRedirect({ fromPath: eventUrl(before.slug), toPath: eventUrl(after.slug) }).catch(() => {});
21
43
  opts.onRedirect?.();
22
44
  }
23
- // the scheduler sends it once the edits settle, if the rule still applies
24
- await queueEventChange(repo, before, after).catch((e) => console.error('acms push: event change not queued', e));
25
- let type: 'event.published' | 'event.updated' | 'event.unpublished' | null = null;
26
- if (before.status !== 'published' && after.status === 'published') type = 'event.published';
27
- else if (before.status === 'published' && after.status !== 'published') type = 'event.unpublished';
28
- else if (after.status === 'published' && (opts.applied || before.updatedAt !== after.updatedAt)) type = 'event.updated';
45
+ // the scheduler sends it once the edits settle, if the rule still applies;
46
+ // a bulk copy (quiet) sends webhooks and redirects, never pushes
47
+ if (before && after && !change.quiet) await queueEventChange(repo, before, after, now).catch((e) => console.error('acms push: event change not queued', e));
48
+ if (!after) {
49
+ if (was) await emitWebhook(repo, 'event.deleted', { event: { id: before.id, slug: before.slug, title: before.title } }, { waitUntil: opts.waitUntil }).catch(() => {});
50
+ return;
51
+ }
52
+ const type = was ? (after.status === 'published' ? 'event.updated' : 'event.unpublished') : after.status === 'published' ? 'event.published' : null;
29
53
  if (!type) return;
30
- const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
31
- const data = eventToJson(after, opts.origin, new Map(calendars.map((c) => [c.id, c])), settings.timezone);
32
- await emitWebhook(repo, type, { event: data }, { waitUntil: opts.waitUntil }).catch(() => {});
54
+ const { origin, settings, calendars, resources } = await site();
55
+ await emitWebhook(repo, type, { event: eventToJson(after, origin, calendars, settings.timezone, { resources }) }, { waitUntil: opts.waitUntil }).catch(() => {});
33
56
  }
34
57
 
35
- export async function eventDeleted(
36
- repo: ContentRepository,
37
- before: CalEvent | null,
38
- opts: { waitUntil?: (p: Promise<unknown>) => void }
39
- ): Promise<void> {
40
- if (before?.status !== 'published') return;
41
- await emitWebhook(repo, 'event.deleted', { event: { id: before.id, slug: before.slug, title: before.title } }, opts).catch(() => {});
58
+ /** Runs the side effects of every recorded change to a published event, oldest first. Returns how many changes it handled. */
59
+ export async function flushEventChanges(repo: ContentRepository, opts: FlushOptions = {}): Promise<number> {
60
+ let site: Promise<Site> | null = null;
61
+ const load = () => (site ??= Promise.all([repo.getSettings(), repo.listCalendars(), repo.listResources()]).then(([settings, calendars, rooms]) => ({
62
+ origin: siteOriginOf(settings, opts.origin), settings, calendars: new Map(calendars.map((c) => [c.id, c])), resources: new Map(rooms.map((r) => [r.id, r])) })));
63
+ let handled = 0;
64
+ for (let round = 0; round < ROUNDS; round++) {
65
+ const now = opts.now ?? Date.now();
66
+ const claimed = await repo.claimEventChanges(now, BATCH, LEASE);
67
+ if (!claimed.length) break;
68
+ const done: number[] = [];
69
+ const failed = new Set<string>();
70
+ for (const change of claimed) {
71
+ // a later change to an event whose earlier one failed waits with it, in order
72
+ if (failed.has(change.eventId)) continue;
73
+ try {
74
+ // a snapshot that no longer parses has nothing to report
75
+ if (change.before || change.after) await apply(repo, change, now, load, opts);
76
+ } catch (e) {
77
+ console.error('acms calendar: event change side effects failed', e);
78
+ if (change.attempts < MAX_ATTEMPTS) { failed.add(change.eventId); continue; }
79
+ }
80
+ done.push(change.seq);
81
+ }
82
+ await repo.finishEventChanges(done);
83
+ handled += done.length;
84
+ if (claimed.length < BATCH) break;
85
+ }
86
+ return handled;
42
87
  }
@@ -2,10 +2,9 @@ import type { ContentRepository } from '../repo/types';
2
2
  import type { CalEvent, EventRequest } from '../schema';
3
3
  import { EventLocation } from '../schema';
4
4
  import { ApiError } from '../api';
5
- import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
5
+ import { applyEventUpdate, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
6
6
  import { CALENDAR } from './config';
7
7
  import { cancelOccurrence, restoreOccurrence, uniqueSlug } from './schedule';
8
- import { eventDeleted } from './hooks';
9
8
  import { waitUntilFrom } from '../webhooks';
10
9
  import { kickEmail } from '../email/service';
11
10
 
@@ -191,7 +190,7 @@ export async function withdrawEventRequest(repo: ContentRepository, who: Actor |
191
190
  // only an event the member's own request made is deleted; a draft staff wrote stays
192
191
  if (e.request.kind === 'new' && e.request.created && !live(e) && !e.publishedAt) {
193
192
  await repo.deleteEvent(id);
194
- await eventDeleted(repo, e, { waitUntil: waitUntilFrom(hooks.locals) });
193
+ await flushEventHooks(repo, hooks);
195
194
  return { deleted: true };
196
195
  }
197
196
  if (e.request.kind === 'changes') await repo.discardEventDraft(id);
@@ -10,10 +10,9 @@ import type { CalEvent } from '../schema';
10
10
  import { EventLocation, slugify } from '../schema';
11
11
  import { defaultPlaceLocation } from '../maps/places';
12
12
  import { ApiError } from '../api';
13
- import { applyEventUpdate, eventSideEffects, saveEvent, type SaveHooks } from '../content-service';
13
+ import { applyEventUpdate, eventSideEffects, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
14
14
  import { addWallDays, findOccurrence, formatWall, parseWall } from './events';
15
15
  import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
16
- import { eventDeleted } from './hooks';
17
16
  import { waitUntilFrom } from '../webhooks';
18
17
  import { kickEmail } from '../email/service';
19
18
 
@@ -114,6 +113,7 @@ export async function moveSeries(repo: ContentRepository, id: string, days: numb
114
113
  // cancelled dates move with the series, like skipped ones
115
114
  if (e.rrule && e.cancelledDates.length) {
116
115
  await repo.setEventCancelledDates(id, e.cancelledDates.map((x) => shift(x, days)));
116
+ await flushEventHooks(repo, hooks);
117
117
  event = (await repo.getEvent(id))!;
118
118
  }
119
119
  return { event, undo: { kind: 'series', id, days: -days } };
@@ -177,7 +177,7 @@ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: S
177
177
  await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
178
178
  }
179
179
  await repo.deleteEvent(copy.id);
180
- await eventDeleted(repo, copy, { waitUntil: waitUntilFrom(hooks.locals) });
180
+ await flushEventHooks(repo, hooks);
181
181
  }
182
182
  }
183
183
 
@@ -202,7 +202,7 @@ async function setOccurrenceCancelled(repo: ContentRepository, id: string, key:
202
202
  await repo.setEventCancelledDates(id, cancel ? [...e.cancelledDates, key].sort() : e.cancelledDates.filter((x) => x !== key));
203
203
  if (cancel && await repo.cancelOccurrenceRsvps(id, o.start)) await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
204
204
  const after = (await repo.getEvent(id))!;
205
- await eventSideEffects(repo, hooks, e, after);
205
+ await eventSideEffects(repo, hooks, after);
206
206
  return after;
207
207
  }
208
208