@acms-subir/core 0.3.45 → 0.3.47

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 (36) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/cli/migrations.generated.mjs +117 -16
  3. package/docs/environment-sync.md +6 -0
  4. package/package.json +1 -1
  5. package/src/actions/index.ts +2 -4
  6. package/src/lib/calendar/agent.ts +3 -4
  7. package/src/lib/calendar/cards.ts +9 -6
  8. package/src/lib/calendar/change-migration.ts +65 -0
  9. package/src/lib/calendar/data.ts +25 -9
  10. package/src/lib/calendar/event-words.ts +124 -0
  11. package/src/lib/calendar/feeds.ts +11 -3
  12. package/src/lib/calendar/filter.ts +140 -0
  13. package/src/lib/calendar/hooks.ts +76 -31
  14. package/src/lib/calendar/render.ts +20 -16
  15. package/src/lib/calendar/requests.ts +2 -3
  16. package/src/lib/calendar/schedule.ts +4 -4
  17. package/src/lib/content-service.ts +15 -12
  18. package/src/lib/mobile/calendar.ts +27 -12
  19. package/src/lib/mobile/http.ts +5 -3
  20. package/src/lib/push/service.ts +1 -1
  21. package/src/lib/repo/migrations.ts +4 -0
  22. package/src/lib/repo/sql.ts +29 -0
  23. package/src/lib/repo/types.ts +21 -0
  24. package/src/lib/scheduler.ts +3 -0
  25. package/src/lib/sync/store.ts +4 -0
  26. package/src/middleware.ts +13 -2
  27. package/src/pages/admin/calendar/events/index.astro +4 -9
  28. package/src/pages/api/calendar/docs.ts +2 -0
  29. package/src/pages/api/calendar/events/[id].ts +3 -3
  30. package/src/pages/api/calendar/events/index.ts +18 -12
  31. package/src/pages/api/webhooks/docs.ts +5 -0
  32. package/src/pages/calendar/[slug].ics.ts +8 -2
  33. package/src/pages/calendar/feed.ics.ts +9 -2
  34. package/src/pages/calendar/index.astro +104 -48
  35. package/src/pages/embed/calendar/[slug].astro +29 -9
  36. package/src/styles/site.css +10 -0
@@ -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
 
@@ -7,7 +7,7 @@ import { deniedMessage } from './permissions/catalog';
7
7
  import { CONTENT, collectionUrl } from './content';
8
8
  import { CALENDAR } from './calendar/config';
9
9
  import { formatWall, isValidTimeZone, normalizeEventTimes, parseWall } from './calendar/events';
10
- import { eventChanged } from './calendar/hooks';
10
+ import { flushEventChanges } from './calendar/hooks';
11
11
  import { offerEventWaitlist } from './booking/service';
12
12
  import { normalizePageLocale } from './i18n';
13
13
  import { siteOrigin } from './seo';
@@ -219,14 +219,18 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
219
219
  return out;
220
220
  }
221
221
 
222
- export async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, before: CalEvent, after: CalEvent, applied = false) {
223
- const settings = await repo.getSettings();
224
- const origin = siteOrigin(settings, hooks.url);
225
- await eventChanged(repo, before, after, {
226
- origin, waitUntil: waitUntilFrom(hooks.locals), applied, onRedirect: hooks.onRedirect,
227
- });
222
+ /** the side effects of event writes made so far (calendar/hooks.ts), at once rather than after the response */
223
+ export function flushEventHooks(repo: ContentRepository, hooks: SaveHooks): Promise<number> {
224
+ return flushEventChanges(repo, { origin: hooks.url.origin, waitUntil: waitUntilFrom(hooks.locals), onRedirect: hooks.onRedirect });
225
+ }
226
+
227
+ export async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, after: CalEvent) {
228
+ await flushEventHooks(repo, hooks);
228
229
  // seats an edit freed (a higher capacity) go to those in line now, not at the next sweep
229
- if (after.rsvp.waitlist) await offerEventWaitlist(repo, after.id, { origin, locals: hooks.locals, waitUntil: waitUntilFrom(hooks.locals) });
230
+ if (after.rsvp.waitlist) {
231
+ const origin = siteOrigin(await repo.getSettings(), hooks.url);
232
+ await offerEventWaitlist(repo, after.id, { origin, locals: hooks.locals, waitUntil: waitUntilFrom(hooks.locals) });
233
+ }
230
234
  }
231
235
 
232
236
  export async function saveEvent(repo: ContentRepository, id: string, data: EventSave, hooks: SaveHooks): Promise<CalEvent> {
@@ -249,17 +253,16 @@ export async function saveEvent(repo: ContentRepository, id: string, data: Event
249
253
  await repo.updateEvent(id, clean);
250
254
  if (clean.tags !== undefined) await repo.ensureTags(clean.tags);
251
255
  const event = (await repo.getEvent(id))!;
252
- await eventSideEffects(repo, hooks, before, event);
256
+ await eventSideEffects(repo, hooks, event);
253
257
  return event;
254
258
  }
255
259
 
256
260
  export async function applyEventUpdate(repo: ContentRepository, id: string, hooks: SaveHooks): Promise<CalEvent> {
257
261
  assertCanPublish(hooks);
258
- const before = await repo.getEvent(id);
259
- if (!before) throw new ApiError(404, 'Event not found.');
262
+ if (!(await repo.getEvent(id))) throw new ApiError(404, 'Event not found.');
260
263
  await repo.applyEventDraft(id);
261
264
  const event = (await repo.getEvent(id))!;
262
265
  if (event.tags.length) await repo.ensureTags(event.tags);
263
- await eventSideEffects(repo, hooks, before, event, true);
266
+ await eventSideEffects(repo, hooks, event);
264
267
  return event;
265
268
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import { publicTags, tagFilter, type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
3
+ import { type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
4
4
  import { ApiError } from '../api';
5
5
  import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
6
6
  import { calendarToJson, eventToJson, roomsToJson } from '../calendar/api';
@@ -17,6 +17,7 @@ import { localizeDoc } from '../translations';
17
17
  import { localeQuery, mobileLocale, localizedUrl } from './locale';
18
18
  import { confirmsInstantly, linkApproval } from '../booking/approval';
19
19
  import { changeTopics } from '../push/service';
20
+ import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../calendar/filter';
20
21
 
21
22
  export function requireCalendar(bookings = false) {
22
23
  if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
@@ -27,14 +28,24 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
27
28
  const origin = siteOrigin(await repo.getSettings(), url);
28
29
  return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
29
30
  }
30
- // tag/excludeTag: a public tag's name or slug (internal "#" tags are never matched, as they are never shown)
31
- export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(), calendar: z.string().min(1).max(200).optional(),
32
- tag: z.string().min(1).max(100).optional(), excludeTag: z.string().min(1).max(100).optional(), q: z.string().max(100).optional() }).strict();
31
+ // The website calendar's filters (calendar/filter.ts). Each takes one or more
32
+ // values, comma-separated or repeated. Tags are a public tag's name or slug
33
+ // (internal "#" tags never match, as they are never shown).
34
+ const FILTER_LISTS = ['calendar', 'cal', 'tag', 'excludeTag', 'notag', 'room'] as const;
35
+ const filterList = (what: string) => z.string().min(1).max(2100)
36
+ .refine((v) => { const parts = v.split(','); return parts.length <= 20 && parts.every((p) => p.trim().length <= 100); }, 'Use up to 20 values of up to 100 characters each.')
37
+ .optional().describe(`${what} One or more, comma-separated (a,b) or repeated.`);
38
+ export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(),
39
+ calendar: filterList('Only events in these calendars (slugs).'), cal: filterList('Alias of calendar.'),
40
+ tag: filterList('Only events with one of these tags (a name in any case, or a slug).'), excludeTag: filterList('Leave out events with any of these tags.'), notag: filterList('Alias of excludeTag.'),
41
+ room: filterList('Only events held in one of these rooms (slugs of rooms shown on public events).'),
42
+ q: z.string().max(100).optional() }).strict();
33
43
  export async function listEvents(repo: ContentRepository, url: URL) {
34
44
  requireCalendar();
35
- const q = eventsQuery.parse(queryParams(url));
45
+ const q = eventsQuery.parse(queryParams(url, FILTER_LISTS));
36
46
  const locale = mobileLocale(q.locale);
37
47
  const term = q.q?.trim() ?? '';
48
+ const filter = readEventFilter(url.searchParams);
38
49
  const [settings, calendars, events, resources, indexed] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), roomMap(repo),
39
50
  // the search index's matches (body text, word prefixes), so "more" from /search continues here
40
51
  term ? repo.searchPublished(term, { limit: 200, collections: [] }).then((r) => new Set(r.events.map(({ doc }) => doc.id))) : null]);
@@ -47,14 +58,18 @@ export async function listEvents(repo: ContentRepository, url: URL) {
47
58
  const to = q.to ? at(q.to) : fromDay.length === 10 ? localMidnight(addDaysYmd(fromDay, 30), tz) : from + 30 * 86400000;
48
59
  if (to <= from || to - from > 90 * 86400000 + 3600000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
49
60
  const all = new Map(calendars.map(c => [c.id, c]));
50
- const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
51
- if (q.calendar && !cal) throw new ApiError(404, 'Calendar not found.');
52
- const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || c.id === cal?.id).map(c => [c.id, c]));
53
- const tagged = tagFilter(q.tag, q.excludeTag);
54
- const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
55
- && tagged(publicTags(e.tags))
61
+ // private calendars and rooms not shown on public events answer like missing ones
62
+ const f = resolveEventFilter(filter, { calendars, resources: [...resources.values()] });
63
+ if (f.calendarIds && f.calendarIds.length < filter.cal.length) throw new ApiError(404, 'Calendar not found.');
64
+ if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Room not found.');
65
+ const picked = f.calendarIds;
66
+ const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || picked?.includes(c.id)).map(c => [c.id, c]));
67
+ const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (picked ? e.calendarIds.some(id => picked.includes(id)) && eventIsPublic(e, all) : eventIsListed(e, all))
68
+ && tagsMatch(f, e.tags)
56
69
  && (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
57
- const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
70
+ const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to })
71
+ // rooms are per date (a moved date can change them)
72
+ .filter(o => !f.roomIds || occurrenceMatches(f, o));
58
73
  occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
59
74
  const start = (q.page - 1) * q.limit;
60
75
  const origin = siteOrigin(settings, url);
@@ -43,11 +43,13 @@ export async function limitRequest(repo: ContentRepository, address: string, wri
43
43
  const key = `mobile:${write ? 'write' : 'read'}:${await sha256Hex(address)}`;
44
44
  if (!await repo.email.rateLimit(key, write ? 20 : 300, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
45
45
  }
46
- export function queryParams(url: URL) {
46
+ // lists: parameters that may repeat (tag=a&tag=b); their values are joined
47
+ // with commas, the same as tag=a,b
48
+ export function queryParams(url: URL, lists: readonly string[] = []) {
47
49
  const result: Record<string, string> = {};
48
50
  for (const [key, value] of url.searchParams) {
49
- if (key in result) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
50
- result[key] = value;
51
+ if (key in result && !lists.includes(key)) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
52
+ result[key] = key in result ? `${result[key]},${value}` : value;
51
53
  }
52
54
  return result;
53
55
  }
@@ -356,7 +356,7 @@ export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Sett
356
356
  return starts.length ? { ...out, at: Math.min(...starts) } : null;
357
357
  }
358
358
 
359
- /** Called by eventChanged on every path: queues (or postpones) a change notice when a published event's schedule or place differs. */
359
+ /** Called for every recorded change to a published event (calendar/hooks.ts): queues (or postpones) a change notice when its schedule or place differs. */
360
360
  export async function queueEventChange(repo: ContentRepository, before: CalEvent, after: CalEvent, now = Date.now()): Promise<boolean> {
361
361
  // publishing is the "new event" notice; unpublishing says nothing
362
362
  if (!PUSH.enabled || !CALENDAR.enabled || before.status !== 'published' || after.status !== 'published') return false;
@@ -5,6 +5,7 @@ import { WEBHOOK_MIGRATION } from '../webhook-sql';
5
5
  import { PUSH_MIGRATION } from '../push/migration';
6
6
  import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
7
7
  import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
8
+ import { EVENT_CHANGES_MIGRATION } from '../calendar/change-migration';
8
9
  import { PLACES_MIGRATION } from '../maps/migration';
9
10
  import { ROLES_MIGRATION } from '../permissions/migration';
10
11
  import { SYNC_MIGRATION } from '../sync/migration';
@@ -720,6 +721,9 @@ export const MIGRATIONS: Migration[] = [
720
721
  `DROP TRIGGER content_version_resources_update`,
721
722
  contentVersionTriggers('resources', [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'])[2],
722
723
  ] },
724
+ // every change to a published event, from any write path, queued for its
725
+ // side effects (webhooks, "event changed" pushes, slug redirects)
726
+ EVENT_CHANGES_MIGRATION,
723
727
  ];
724
728
 
725
729
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -1030,6 +1030,33 @@ export class SqlRepository implements ContentRepository {
1030
1030
  if (patch.live) await this.syncEventIndex(id);
1031
1031
  }
1032
1032
 
1033
+ async claimEventChanges(now: number, limit: number, leaseMs: number) {
1034
+ await this.ready;
1035
+ const rows = await this.driver.all(
1036
+ `UPDATE event_changes SET claimed_until=?, attempts=attempts+1 WHERE seq IN (
1037
+ SELECT seq FROM event_changes WHERE claimed_until<=?
1038
+ AND event_id NOT IN (SELECT event_id FROM event_changes WHERE claimed_until>?)
1039
+ ORDER BY seq LIMIT ?) RETURNING *`,
1040
+ [now + leaseMs, now, now, limit]
1041
+ );
1042
+ return rows.map((r) => {
1043
+ // a snapshot that no longer parses drops the whole change (never a fake insert or delete)
1044
+ let before: CalEvent | null = null, after: CalEvent | null = null;
1045
+ try {
1046
+ before = r.old_row ? rowToEvent(JSON.parse(String(r.old_row))) : null;
1047
+ after = r.new_row ? rowToEvent(JSON.parse(String(r.new_row))) : null;
1048
+ } catch { before = after = null; }
1049
+ return { seq: Number(r.seq), eventId: String(r.event_id), before, after, quiet: Boolean(r.quiet), attempts: Number(r.attempts), createdAt: Number(r.created_at) };
1050
+ }).sort((a, b) => a.seq - b.seq);
1051
+ }
1052
+ async finishEventChanges(seqs: number[]) {
1053
+ await this.ready;
1054
+ for (let i = 0; i < seqs.length; i += 200) {
1055
+ const chunk = seqs.slice(i, i + 200);
1056
+ await this.driver.run(`DELETE FROM event_changes WHERE seq IN (${chunk.map(() => '?').join(', ')})`, chunk);
1057
+ }
1058
+ }
1059
+
1033
1060
  // ---- places ----
1034
1061
  private placeInsert(p: Place): SqlStatement {
1035
1062
  return {
@@ -1866,6 +1893,8 @@ export class SqlRepository implements ContentRepository {
1866
1893
  // Suppression and re-enabling share the restore transaction. A failed
1867
1894
  // restore cannot leave notifications disabled or emit phantom deletes.
1868
1895
  { sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
1896
+ // changes made before the restore describe content it replaces
1897
+ { sql: 'DELETE FROM event_changes' },
1869
1898
  { sql: "UPDATE auth_invitations SET state='revoked' WHERE state<>'accepted'" },
1870
1899
  ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records', 'places', 'forms', 'form_submissions'].map((t) => ({
1871
1900
  sql: `DELETE FROM ${t}`,
@@ -54,6 +54,21 @@ export type EventUpdate = Partial<
54
54
  >
55
55
  >;
56
56
 
57
+ /** One recorded change to a published event (the event_changes outbox, written by triggers). */
58
+ export interface EventChange {
59
+ seq: number;
60
+ eventId: string;
61
+ /** the event as it was: null when it was inserted */
62
+ before: CalEvent | null;
63
+ /** the event as written: null when it was deleted */
64
+ after: CalEvent | null;
65
+ /** written by a bulk copy (environment sync): no member pushes */
66
+ quiet: boolean;
67
+ /** times it was claimed, this one included */
68
+ attempts: number;
69
+ createdAt: number;
70
+ }
71
+
57
72
  // The single contract both backends implement. The app only ever imports
58
73
  // this — never a concrete adapter.
59
74
  export interface ContentRepository {
@@ -168,6 +183,12 @@ export interface ContentRepository {
168
183
  // draft itself — alone. notify = RSVP guests of a published event get an
169
184
  // "event updated" email (the address changed).
170
185
  setEventLocation(id: string, patch: { live?: EventLocation; draft?: EventLocation; notify?: boolean }): Promise<void>;
186
+ // changes to published events waiting for their side effects
187
+ // (lib/calendar/hooks.ts), oldest first. A claim holds the rows for
188
+ // `leaseMs` and skips events another claim still holds, so two runners
189
+ // never handle one event's changes out of order. finish removes them.
190
+ claimEventChanges(now: number, limit: number, leaseMs: number): Promise<EventChange[]>;
191
+ finishEventChanges(seqs: number[]): Promise<void>;
171
192
 
172
193
  // saved places. Only one can be the default (saving one as default clears
173
194
  // the others). Deleting a place unlinks its rooms; events keep their copy.
@@ -1,6 +1,7 @@
1
1
  import { syncDueLive } from './live/service';
2
2
  import type { ContentRepository } from './repo/types';
3
3
  import { processDueDeliveries } from './webhooks';
4
+ import { flushEventChanges } from './calendar/hooks';
4
5
  import { dispatchEmail } from './email/service';
5
6
  import { decideOverdue, sendDueReminders, sweepWaitlist } from './booking/service';
6
7
  import { runPushJobs } from './push/service';
@@ -17,6 +18,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
17
18
  const settings = await repo.getSettings();
18
19
  const jobs = {
19
20
  publish: () => repo.publishDuePosts(startedAt),
21
+ // changes to published events (scheduled publishing above included): webhooks, change pushes, redirects
22
+ events: () => flushEventChanges(repo),
20
23
  webhooks: () => processDueDeliveries(repo),
21
24
  reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
22
25
  // pending bookings past their approval deadline (Calendar settings → Bookings)
@@ -327,7 +327,11 @@ export class SyncStore {
327
327
  sql: "INSERT INTO revision_checks(expected,actual) SELECT 1,CASE WHEN result IS NULL THEN 1 ELSE 0 END FROM sync_plans WHERE id=?",
328
328
  args: [planId],
329
329
  },
330
+ // a transfer is a bulk copy: its event changes send webhooks but
331
+ // never member pushes (the flag shares this transaction)
332
+ { sql: "UPDATE push_control SET quiet=1 WHERE id=1" },
330
333
  ...statements,
334
+ { sql: "UPDATE push_control SET quiet=0 WHERE id=1" },
331
335
  {
332
336
  sql: "UPDATE sync_plans SET result=json_set(?,'$.versions',json((SELECT json_group_object(entity,revision) FROM sync_versions WHERE entity IN (SELECT value FROM json_each(?))))) WHERE id=?",
333
337
  args: [JSON.stringify(result), JSON.stringify(versionKeys), planId],
package/src/middleware.ts CHANGED
@@ -7,6 +7,7 @@ import { AUTH } from './lib/auth/config';
7
7
  import { getRepo } from './lib/repo';
8
8
  import { CONTENT, legacyBases, localizesRedirectsTo } from './lib/content';
9
9
  import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
10
+ import { flushEventChanges } from './lib/calendar/hooks';
10
11
  import { dispatchEmail } from './lib/email/service';
11
12
  import { decideOverdue, sendDueReminders } from './lib/booking/service';
12
13
  import { PUSH } from './lib/push/config';
@@ -151,10 +152,14 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
151
152
  try {
152
153
  const repo = await getRepo(context.locals);
153
154
  await repo.publishDuePosts(now);
155
+ const wait = waitUntilFrom(context.locals);
156
+ // scheduled events that just went live get their webhooks (calendar/hooks.ts)
157
+ const changes = flushEventChanges(repo, { origin: context.url.origin, waitUntil: wait, onRedirect: invalidateRedirectCache })
158
+ .catch((e) => console.error('acms calendar: event changes not flushed', e));
159
+ if (wait) wait(changes); else await changes;
154
160
  // webhook retries ride the same once-a-minute sweep (Node has no
155
161
  // cron) — in the background, never holding up this request
156
162
  const emails = dispatchEmail(repo, context.locals, 2).catch(() => 0);
157
- const wait = waitUntilFrom(context.locals);
158
163
  if (wait) wait(emails); else await emails;
159
164
  const retries = processDueDeliveries(repo).catch(() => {});
160
165
  if (wait) wait(retries); else await retries;
@@ -186,8 +191,14 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
186
191
  // Flush after mutations (including Astro actions), once their transaction
187
192
  // has committed. The scheduler remains the durable fallback on failure.
188
193
  if (!['GET', 'HEAD', 'OPTIONS'].includes(context.request.method)) {
189
- const deliveries = getRepo(context.locals).then(repo => processDueDeliveries(repo, { limit: 5 })).catch(() => 0);
190
194
  const wait = waitUntilFrom(context.locals);
195
+ // whatever this request changed on published events, by any path: its
196
+ // webhooks, "event changed" pushes and slug redirects (calendar/hooks.ts)
197
+ const changes = getRepo(context.locals)
198
+ .then(repo => flushEventChanges(repo, { origin: context.url.origin, waitUntil: wait, onRedirect: invalidateRedirectCache }))
199
+ .catch((e) => console.error('acms calendar: event changes not flushed', e));
200
+ if (wait) wait(changes); else await changes;
201
+ const deliveries = getRepo(context.locals).then(repo => processDueDeliveries(repo, { limit: 5 })).catch(() => 0);
191
202
  if (wait) wait(deliveries); else await deliveries;
192
203
  // first-publish push notices: background only, never delaying the response
193
204
  if (PUSH.enabled) {
@@ -14,7 +14,7 @@ import { describeRepeat } from '../../../../lib/calendar/rrule';
14
14
  import { expandVEvents, loadFeed, tzParts } from '../../../../lib/calendar/ics';
15
15
  import { normalizeCalendarSrc } from '../../../../lib/calendar/src';
16
16
  import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../../../../lib/calendar/import';
17
- import { eventChanged, eventDeleted } from '../../../../lib/calendar/hooks';
17
+ import { flushEventChanges } from '../../../../lib/calendar/hooks';
18
18
  import { siteOrigin } from '../../../../lib/seo';
19
19
  import { siteLocale } from '../../../../lib/dates';
20
20
  import { waitUntilFrom } from '../../../../lib/webhooks';
@@ -69,7 +69,6 @@ async function saveImported(list: ReturnType<typeof eventsFromIcs>['events'], ca
69
69
  if (Astro.request.method === 'POST') {
70
70
  const form = await Astro.request.formData();
71
71
  const op = String(form.get('op'));
72
- const hookOpts = { origin: siteOrigin(settings, Astro.url), waitUntil: waitUntilFrom(Astro.locals), onRedirect: invalidateRedirectCache };
73
72
  // create, copy and import make drafts; bulk status changes that touch live events need Publish
74
73
  let needs: Permission = 'content.edit';
75
74
  // answering members' event requests needs Publish (approving puts them live)
@@ -126,16 +125,12 @@ if (Astro.request.method === 'POST') {
126
125
  if (op === 'bulk') {
127
126
  const to = String(form.get('to'));
128
127
  for (const id of form.getAll('ids').map(String).filter(Boolean)) {
129
- const before = await repo.getEvent(id);
130
- if (!before) continue;
131
- if (to === 'delete') {
132
- await repo.deleteEvent(id);
133
- await eventDeleted(repo, before, hookOpts);
134
- } else if (to === 'published' || to === 'draft' || to === 'archived') {
128
+ if (to === 'delete') await repo.deleteEvent(id);
129
+ else if (to === 'published' || to === 'draft' || to === 'archived') {
135
130
  await repo.updateEvent(id, { status: to as Status, ...(to === 'published' ? { scheduledAt: null } : {}) });
136
- await eventChanged(repo, before, (await repo.getEvent(id))!, hookOpts);
137
131
  }
138
132
  }
133
+ await flushEventChanges(repo, { origin: siteOrigin(settings, Astro.url), waitUntil: waitUntilFrom(Astro.locals), onRedirect: invalidateRedirectCache });
139
134
  return back();
140
135
  }
141
136
 
@@ -59,6 +59,8 @@ Add endpoints in Team → API & webhooks. Events: event.published, event.updated
59
59
  Each POST has headers X-Acms-Event, X-Acms-Delivery, X-Acms-Timestamp and
60
60
  X-Acms-Signature: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>.
61
61
  Body: { "id", "type", "createdAt", "data": { ... } }. Failed deliveries retry after 1, 10 and 60 minutes.
62
+ event.* fire however an event changes: the editor, Schedule, this API, agent tools, scheduled
63
+ publishing, a saved place's new address and environment sync. Restores send site.restored instead.
62
64
 
63
65
  ## MCP / agent tools
64
66
 
@@ -7,7 +7,7 @@ import { CALENDAR } from '../../../../lib/calendar/config';
7
7
  import { eventIsPublic } from '../../../../lib/calendar/events';
8
8
  import { eventToJson } from '../../../../lib/calendar/api';
9
9
  import { runAgentTool } from '../../../../lib/agent';
10
- import { eventDeleted } from '../../../../lib/calendar/hooks';
10
+ import { flushEventChanges } from '../../../../lib/calendar/hooks';
11
11
  import { mergeEventDraft } from '../../../../lib/drafts';
12
12
  import { waitUntilFrom } from '../../../../lib/webhooks';
13
13
  import { fail, preflight, withCors } from '../../../../lib/calendar/http';
@@ -50,14 +50,14 @@ export const PATCH: APIRoute = async ({ request, locals, url, params }) => {
50
50
  } catch (err) { return fail(err); }
51
51
  };
52
52
 
53
- export const DELETE: APIRoute = async ({ request, locals, params }) => {
53
+ export const DELETE: APIRoute = async ({ request, locals, url, params }) => {
54
54
  try {
55
55
  const repo = await getRepo(locals);
56
56
  await requireScope(request, repo, 'events:write');
57
57
  const e = await find(repo, params.id!);
58
58
  if (!e) throw new ApiError(404, 'Event not found.');
59
59
  await repo.deleteEvent(e.id);
60
- await eventDeleted(repo, e, { waitUntil: waitUntilFrom(locals) });
60
+ await flushEventChanges(repo, { origin: url.origin, waitUntil: waitUntilFrom(locals) });
61
61
  return withCors(json({ deleted: e.id }));
62
62
  } catch (err) { return fail(err); }
63
63
  };
@@ -14,16 +14,20 @@ import { readJson } from '../../../../lib/api';
14
14
  import { runAgentTool } from '../../../../lib/agent';
15
15
  import { waitUntilFrom } from '../../../../lib/webhooks';
16
16
  import { fail } from '../../../../lib/calendar/http';
17
- import { publicTags, tagFilter } from '../../../../lib/schema';
17
+ import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../../../../lib/calendar/filter';
18
18
 
19
19
  // Public read API for other sites, apps and lobby screens.
20
20
  // GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
21
21
  // GET /api/calendar/events?tag=class (or excludeTag=class; a tag's name or slug)
22
+ // GET /api/calendar/events?calendar=youth,events&notag=Class&room=chapel
23
+ // (the /calendar page's filters: each takes several values, comma-separated
24
+ // or repeated; cal and notag are aliases of calendar and excludeTag)
22
25
  // → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] }
23
26
  // GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
24
27
  // Anonymous callers see public calendars (and an unlisted one when asked by
25
28
  // slug). A key with events:read also sees private calendars and private
26
- // custom fields, and its tag filters also match internal "#" tags.
29
+ // custom fields, its tag filters also match internal "#" tags, and its room
30
+ // filter matches every room (anonymous callers: rooms shown on public events).
27
31
  const CORS = {
28
32
  'Access-Control-Allow-Origin': '*',
29
33
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -43,28 +47,28 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
43
47
  const principal = await authenticate(request, repo);
44
48
  if (request.headers.get('authorization') && !principal) throw new ApiError(401, 'Invalid API key.');
45
49
  const full = principal ? hasScope(principal, 'events:read') : false;
50
+ const filter = readEventFilter(url.searchParams);
46
51
  const [settings, calendars, published, rooms] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), repo.listResources()]);
47
52
  const tz = settings.timezone || 'UTC';
48
53
  const map = new Map(calendars.map((c) => [c.id, c]));
49
54
  const resources = new Map(rooms.map((r) => [r.id, r]));
50
55
  const origin = siteOrigin(settings, url);
51
56
 
52
- const slug = url.searchParams.get('calendar');
53
- const cal = slug ? calendars.find((c) => c.slug === slug) : null;
54
- if (slug && (!cal || (cal.visibility === 'private' && !full))) throw new ApiError(404, 'Unknown calendar.');
57
+ const f = resolveEventFilter(filter, { calendars, resources: rooms, allowPrivate: full });
58
+ if (f.calendarIds && f.calendarIds.length < filter.cal.length) throw new ApiError(404, 'Unknown calendar.');
59
+ if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Unknown room.');
60
+ const picked = f.calendarIds;
55
61
  const q = (url.searchParams.get('q') ?? '').trim().toLowerCase();
56
62
  let events = published.filter((e) => {
57
- if (cal) return e.calendarIds.includes(cal.id) && (full || eventIsPublic(e, map));
63
+ if (picked) return e.calendarIds.some((id) => picked.includes(id)) && (full || eventIsPublic(e, map));
58
64
  return full ? true : eventIsListed(e, map);
59
65
  });
60
66
  if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
61
- const tag = url.searchParams.get('tag')?.slice(0, 100), excludeTag = url.searchParams.get('excludeTag')?.slice(0, 100);
62
- if (tag || excludeTag) {
63
- const tagged = tagFilter(tag, excludeTag);
64
- events = events.filter((e) => tagged(full ? e.tags : publicTags(e.tags)));
65
- }
67
+ events = events.filter((e) => tagsMatch(f, e.tags, { internal: full }));
66
68
 
67
69
  if (url.searchParams.get('expand') === '0') {
70
+ // rules, not dates: a room filter matches the series' own rooms
71
+ if (f.roomIds) events = events.filter((e) => occurrenceMatches(f, { event: e }));
68
72
  return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full, resources })) }));
69
73
  }
70
74
  const day = (param: string, fallback: number) => {
@@ -76,7 +80,9 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
76
80
  const to = Math.min(day('to', from + 90 * 86_400_000), from + 400 * 86_400_000);
77
81
  if (to <= from) throw new ApiError(400, '"to" must be after "from".');
78
82
  const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get('limit')) || 200));
79
- const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to }).slice(0, limit);
83
+ const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to })
84
+ .filter((o) => !f.roomIds || occurrenceMatches(f, o))
85
+ .slice(0, limit);
80
86
  const cache = new Map<string, ReturnType<typeof eventToJson>>();
81
87
  const toJson = (id: string) => {
82
88
  if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone, { includePrivate: full, resources }));
@@ -24,6 +24,11 @@ Restoring design history only stages a draft; publishing it emits this event.
24
24
  media.created/updated/deleted: a media record is added, its alt text changes, or it is removed.
25
25
  site.restored: a content backup is restored; resync instead of expecting individual record events.
26
26
  announcement.published: an announcement goes up (acms({ announcements }); scheduled ones when their time comes).
27
+ event.published/updated/unpublished/deleted: a calendar event goes public, changes, stops being
28
+ public or is deleted, by any path: the editor, Schedule, list actions, agent tools, the REST API,
29
+ scheduled publishing, a saved place's new address and environment sync. event.updated means
30
+ something public changed (times, place, rooms, cancelled dates, text, translations); hosts,
31
+ members' requests and staged edits do not emit it. A restore sends site.restored instead.
27
32
  Existing event.* and booking.* payloads are described at /api/calendar/docs.
28
33
 
29
34
  ## Payloads
@@ -4,10 +4,13 @@ import { getRepo } from '../../lib/repo';
4
4
  import { siteOrigin } from '../../lib/seo';
5
5
  import { CALENDAR } from '../../lib/calendar/config';
6
6
  import { feedFor, icsResponse } from '../../lib/calendar/feeds';
7
+ import { readEventFilter, resolveEventFilter } from '../../lib/calendar/filter';
7
8
 
8
9
  // One calendar's subscribe feed. Public and unlisted calendars are open;
9
10
  // private ones need the secret ?t= token from Calendars → Copy link (staff
10
11
  // subscriptions). A wrong token looks exactly like a missing calendar.
12
+ // ?tag=, ?notag= and ?room= narrow it like the /calendar page (a `cal` here
13
+ // is ignored: the address names the calendar).
11
14
  const sameToken = (a: string, b: string) =>
12
15
  a.length === b.length && a.length > 0 && timingSafeEqual(Buffer.from(a), Buffer.from(b));
13
16
 
@@ -18,8 +21,11 @@ export const GET: APIRoute = async ({ locals, url, params }) => {
18
21
  if (!calendar || calendar.kind !== 'native') return new Response(null, { status: 404 });
19
22
  const isPrivate = calendar.visibility === 'private';
20
23
  if (isPrivate && !sameToken(url.searchParams.get('t') ?? '', calendar.feedToken)) return new Response(null, { status: 404 });
21
- const settings = await repo.getSettings();
22
- const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { calendar, allowPrivate: isPrivate });
24
+ const filter = { ...readEventFilter(url.searchParams), cal: [] };
25
+ const [settings, resources] = await Promise.all([repo.getSettings(), filter.room.length ? repo.listResources() : []]);
26
+ const resolved = resolveEventFilter(filter, { calendars: [], resources, allowPrivate: isPrivate });
27
+ if (resolved.unknown) return new Response(null, { status: 404 });
28
+ const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { calendar, allowPrivate: isPrivate, filter: resolved });
23
29
  // private feeds must never sit in shared caches
24
30
  const res = icsResponse(body, `${calendar.slug}.ics`, isPrivate ? 0 : 300);
25
31
  if (isPrivate) res.headers.set('Cache-Control', 'private, no-store');
@@ -3,13 +3,20 @@ import { getRepo } from '../../lib/repo';
3
3
  import { siteOrigin } from '../../lib/seo';
4
4
  import { CALENDAR } from '../../lib/calendar/config';
5
5
  import { feedFor, icsResponse } from '../../lib/calendar/feeds';
6
+ import { readEventFilter, resolveEventFilter } from '../../lib/calendar/filter';
6
7
 
7
8
  // Every public calendar in one subscribe feed (/calendar.ics). Feed
8
9
  // calendars are not re-published — subscribers can add those at the source.
10
+ // The /calendar page's filters narrow it: /calendar.ics?tag=Class,
11
+ // ?cal=youth,events&notag=Class, ?room=chapel. A calendar or room that isn't
12
+ // public answers 404, like one that doesn't exist.
9
13
  export const GET: APIRoute = async ({ locals, url }) => {
10
14
  if (!CALENDAR.enabled) return new Response(null, { status: 404 });
11
15
  const repo = await getRepo(locals);
12
- const settings = await repo.getSettings();
13
- const { body } = await feedFor(repo, settings, siteOrigin(settings, url), {});
16
+ const filter = readEventFilter(url.searchParams);
17
+ const [settings, calendars, resources] = await Promise.all([repo.getSettings(), repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
18
+ const resolved = resolveEventFilter(filter, { calendars, resources });
19
+ if (resolved.unknown) return new Response(null, { status: 404 });
20
+ const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { filter: resolved });
14
21
  return icsResponse(body, 'calendar.ics');
15
22
  };