@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.47
4
+
5
+ ### Patch Changes
6
+
7
+ - 26f2060: The public calendar can be filtered from its address, and speaks the page's language.
8
+
9
+ **Filters.** `/calendar?tag=Class` shows only classes, `/calendar?notag=Class&cal=events` shows the Events calendar without them, and `/calendar?room=chapel` shows what happens in a room. `tag` keeps events with any of its tags, `notag` (alias `excludeTag`) leaves them out, `cal` (alias `calendar`) now takes several calendars, and `room` takes room slugs. Each can repeat (`tag=a&tag=b`) or be comma-separated (`tag=a,b`); different filters must all match. Tags keep their rules (a name in any case, or its slug; internal `#` tags never match publicly). Only rooms shown on public events match, and a private calendar or a hidden room answers exactly like one that doesn't exist, so a link can't probe for them. Events from linked feed calendars and "Reserved" booking blocks have no tags: `tag` leaves them out, `notag` keeps them, and a room filter keeps a room's own Reserved blocks.
10
+
11
+ On `/calendar` the calendar chips now switch calendars on and off, so several can be picked; tags, left-out tags and rooms show as removable chips with **Clear filters**; and view links, chips and **Subscribe** keep the filters. A filtered page subscribes to `/calendar.ics` with the same filters (one calendar alone keeps its own feed). The same parameters work on `/embed/calendar/<slug|all>` (`cal` adds calendars to the one in the address), `/calendar.ics`, `/calendar/<slug>.ics` (`tag`, `notag`, `room`), `/api/v1/events` (now documented in the OpenAPI document; repeating them is allowed, other parameters still may not repeat) and `/api/calendar/events`, where a key with `events:read` also matches private calendars, every room and `#` tags. An unknown, private or hidden calendar or room is a 404 on the APIs and feeds, and an empty calendar with its chip on the page.
12
+
13
+ **Languages.** `/es/calendar` is in Spanish like event pages: the headings, chips, views, Subscribe menu, agenda, empty states, the grid's Today/Upcoming/Details and the dates and time zone. Event titles use their translations, and every link on the page (views, chips, events, cards) stays under `/es`. French, German and Portuguese work the same way. Embeds take `?lang=es` (one of the site's languages). The calendar card ("No calendar connected", "Calendar unavailable right now", "Open in Google Calendar") and event cards ("Untitled event", "No upcoming events") also speak their dates' language.
14
+
15
+ ## 0.3.46
16
+
17
+ ### Patch Changes
18
+
19
+ - 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).
20
+
21
+ - **Scheduled publishing** behaves like pressing Publish: `event.published`, with the same payload.
22
+ - **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.
23
+ - **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).
24
+ - **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.
25
+ - **Restores** still send only `site.restored`. Changes still waiting from before the restore are dropped.
26
+ - **Drafts** (new events, ICS/CSV imports, Duplicate, "Make an editable copy", members' requests) send nothing until they're published.
27
+ - `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).
28
+
29
+ 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`.
30
+
3
31
  ## 0.3.45
4
32
 
5
33
  ### Patch Changes
@@ -84,24 +84,24 @@ function trigger(name, action, table, when, event, data) {
84
84
  function content(kind) {
85
85
  const table = `${kind}s`;
86
86
  const fields = kind === "post" ? ["collection", "slug", "title", "status", "body", "body_json", "body_html", "excerpt", "tags", "cover", "cover_alt", "seo_title", "seo_desc", "featured", "author_id", "template", "published_at"] : ["slug", "title", "status", "is_home", "locale", "blocks", "body_json", "body_html", "seo_title", "seo_desc", "template"];
87
- const snapshot = (row) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
87
+ const snapshot2 = (row) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
88
88
  ${kind === "post" ? `'collection',${row}.collection` : `'locale',${row}.locale,'isHome',json(CASE WHEN ${row}.is_home THEN 'true' ELSE 'false' END)`})`;
89
- const changed = fields.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(" OR ");
89
+ const changed2 = fields.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(" OR ");
90
90
  const event = `CASE WHEN OLD.status!='published' THEN '${kind}.published'
91
91
  WHEN NEW.status!='published' THEN '${kind}.unpublished' ELSE '${kind}.updated' END`;
92
92
  return [
93
- trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot("NEW")})`),
93
+ trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot2("NEW")})`),
94
94
  trigger(
95
95
  `webhook_${kind}_update`,
96
96
  "UPDATE",
97
97
  table,
98
- `(OLD.status='published' OR NEW.status='published') AND (${changed})`,
98
+ `(OLD.status='published' OR NEW.status='published') AND (${changed2})`,
99
99
  event,
100
- `json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${snapshot("NEW")}
101
- ELSE json_set(${snapshot("OLD")},'$.status',NEW.status) END),
102
- 'previous',json(CASE WHEN OLD.status='published' THEN ${snapshot("OLD")} ELSE 'null' END))`
100
+ `json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${snapshot2("NEW")}
101
+ ELSE json_set(${snapshot2("OLD")},'$.status',NEW.status) END),
102
+ 'previous',json(CASE WHEN OLD.status='published' THEN ${snapshot2("OLD")} ELSE 'null' END))`
103
103
  ),
104
- trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot("OLD")})`)
104
+ trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot2("OLD")})`)
105
105
  ];
106
106
  }
107
107
  var media = (row) => `json_object('media',json_object('id',${row}.id,'key',${row}.key,'filename',${row}.filename,
@@ -218,6 +218,104 @@ var ROOM_HOLD_MIGRATION = { version: 41, statements: [
218
218
  `ALTER TABLE events ADD COLUMN room_hold TEXT NOT NULL DEFAULT '{}'`
219
219
  ] };
220
220
 
221
+ // src/lib/calendar/change-migration.ts
222
+ var now4 = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
223
+ var EVENT_CHANGE_SNAPSHOT = [
224
+ "id",
225
+ "slug",
226
+ "title",
227
+ "excerpt",
228
+ "cover",
229
+ "cover_alt",
230
+ "status",
231
+ "featured",
232
+ "tags",
233
+ "starts",
234
+ "ends",
235
+ "all_day",
236
+ "timezone",
237
+ "rrule",
238
+ "exdates",
239
+ "cancelled",
240
+ "cancelled_dates",
241
+ "location",
242
+ "cta",
243
+ "calendar_ids",
244
+ "fields",
245
+ "rsvp",
246
+ "room_hold",
247
+ "start_at",
248
+ "end_at",
249
+ "published_at",
250
+ "scheduled_at",
251
+ "created_at",
252
+ "updated_at"
253
+ ];
254
+ var EVENT_CHANGE_COLUMNS = [
255
+ "slug",
256
+ "title",
257
+ "body_json",
258
+ "body_html",
259
+ "excerpt",
260
+ "cover",
261
+ "cover_alt",
262
+ "seo_title",
263
+ "seo_desc",
264
+ "status",
265
+ "featured",
266
+ "tags",
267
+ "template",
268
+ "starts",
269
+ "ends",
270
+ "all_day",
271
+ "timezone",
272
+ "rrule",
273
+ "exdates",
274
+ "cancelled",
275
+ "cancelled_dates",
276
+ "location",
277
+ "cta",
278
+ "calendar_ids",
279
+ "fields",
280
+ "rsvp",
281
+ "translations",
282
+ "room_hold",
283
+ "start_at",
284
+ "end_at"
285
+ ];
286
+ var snapshot = (row) => `json_object(${EVENT_CHANGE_SNAPSHOT.map((c) => `'${c}',${row}.${c}`).join(",")})`;
287
+ var live = "(SELECT suppressed FROM webhook_control WHERE id=1)=0";
288
+ var quiet = "COALESCE((SELECT quiet FROM push_control WHERE id=1),0)";
289
+ var record = (id, before, after) => `INSERT INTO event_changes (event_id,old_row,new_row,quiet,created_at)
290
+ VALUES (${id},${before},${after},${quiet},${now4});`;
291
+ var changed = EVENT_CHANGE_COLUMNS.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
292
+ var skipped = `CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 OR ${quiet}=1 THEN 'skipped' ELSE 'pending' END`;
293
+ var ledger = `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
294
+ SELECT 'event:'||NEW.id,'event',NEW.id,NULL,${skipped},NULL,${now4},${now4}
295
+ WHERE NOT EXISTS (SELECT 1 FROM push_queue WHERE key='event:'||NEW.id);`;
296
+ var EVENT_CHANGES_MIGRATION = { version: 65, statements: [
297
+ `CREATE TABLE event_changes (
298
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL, old_row TEXT, new_row TEXT,
299
+ quiet INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, claimed_until INTEGER NOT NULL DEFAULT 0,
300
+ created_at INTEGER NOT NULL
301
+ )`,
302
+ `CREATE INDEX event_changes_event ON event_changes (event_id, claimed_until)`,
303
+ `CREATE TABLE push_control (id INTEGER PRIMARY KEY CHECK(id=1), quiet INTEGER NOT NULL DEFAULT 0)`,
304
+ `INSERT INTO push_control (id,quiet) VALUES (1,0)`,
305
+ `CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
306
+ BEGIN ${record("NEW.id", "NULL", snapshot("NEW"))} END`,
307
+ `CREATE TRIGGER event_changes_update AFTER UPDATE ON events
308
+ WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
309
+ BEGIN ${record("NEW.id", snapshot("OLD"), snapshot("NEW"))} END`,
310
+ `CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
311
+ BEGIN ${record("OLD.id", snapshot("OLD"), "NULL")} END`,
312
+ `DROP TRIGGER push_event_insert`,
313
+ `DROP TRIGGER push_event_publish`,
314
+ `CREATE TRIGGER push_event_insert AFTER INSERT ON events WHEN NEW.status='published' BEGIN ${ledger} END`,
315
+ `CREATE TRIGGER push_event_publish AFTER UPDATE OF status ON events
316
+ WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${ledger} END`
317
+ ] };
318
+
221
319
  // src/lib/maps/migration.ts
222
320
  var PLACES_MIGRATION = { version: 43, statements: [
223
321
  `CREATE TABLE IF NOT EXISTS places (
@@ -468,8 +566,8 @@ var CONTENT_VERSION_COLUMNS = {
468
566
  };
469
567
  var BUMP_CONTENT_VERSION = "UPDATE settings SET content_version = content_version + 1 WHERE id = 1;";
470
568
  function contentVersionTriggers(table, columns) {
471
- const changed = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
472
- const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${changed} BEGIN ${BUMP_CONTENT_VERSION} END`;
569
+ const changed2 = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
570
+ const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${changed2} BEGIN ${BUMP_CONTENT_VERSION} END`;
473
571
  if (table === "settings") return [update];
474
572
  return [
475
573
  `CREATE TRIGGER content_version_${table}_insert AFTER INSERT ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
@@ -1146,7 +1244,10 @@ var MIGRATIONS = [
1146
1244
  `ALTER TABLE resources ADD COLUMN requestable INTEGER NOT NULL DEFAULT 0`,
1147
1245
  `DROP TRIGGER content_version_resources_update`,
1148
1246
  contentVersionTriggers("resources", [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"])[2]
1149
- ] }
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
1150
1251
  ];
1151
1252
  var CURRENT_CONTENT_VERSION_COLUMNS = {
1152
1253
  ...CONTENT_VERSION_COLUMNS,
@@ -1175,7 +1276,7 @@ async function pendingMigrations(db) {
1175
1276
  return { applied, pending: MIGRATIONS.filter((m) => m.version > applied) };
1176
1277
  }
1177
1278
  async function runMigrations(db, opts = {}) {
1178
- const now4 = opts.now ?? Date.now;
1279
+ const now5 = opts.now ?? Date.now;
1179
1280
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1180
1281
  const first = await pendingMigrations(db);
1181
1282
  if (!first.pending.length) return { from: first.applied, to: first.applied, applied: [] };
@@ -1183,10 +1284,10 @@ async function runMigrations(db, opts = {}) {
1183
1284
  if (refused) return { from: first.applied, to: first.applied, applied: [], deferred: refused.version };
1184
1285
  await db.run(MIGRATION_LOCK_TABLE);
1185
1286
  const owner = crypto.randomUUID();
1186
- const deadline = now4() + (opts.waitMs ?? 6e4);
1287
+ const deadline = now5() + (opts.waitMs ?? 6e4);
1187
1288
  let waited = false;
1188
1289
  for (; ; ) {
1189
- const t = now4();
1290
+ const t = now5();
1190
1291
  await db.run(
1191
1292
  `INSERT INTO migration_lock (id, owner, expires_at) VALUES (1, ?, ?)
1192
1293
  ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at WHERE migration_lock.expires_at < ?`,
@@ -1204,13 +1305,13 @@ async function runMigrations(db, opts = {}) {
1204
1305
  try {
1205
1306
  const { applied, pending } = await pendingMigrations(db);
1206
1307
  if (applied === 1 && !await appliedVersion(db)) {
1207
- await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [now4()]);
1308
+ await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [now5()]);
1208
1309
  }
1209
1310
  for (const m of pending) {
1210
1311
  opts.log?.(`Applying migration ${m.version}`);
1211
1312
  await db.batch([
1212
1313
  ...m.statements.map((sql) => ({ sql })),
1213
- { sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version, now4()] }
1314
+ { sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version, now5()] }
1214
1315
  ]);
1215
1316
  }
1216
1317
  return { from: applied, to: pending.length ? pending[pending.length - 1].version : applied, applied: pending.map((m) => m.version) };
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.45",
3
+ "version": "0.3.47",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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,9 +1026,8 @@ export const server = {
1027
1026
  handler: async ({ id }, ctx) => {
1028
1027
  await requireCan(ctx, 'content.delete');
1029
1028
  const repo = await getRepo(ctx.locals);
1030
- const before = await repo.getEvent(id);
1031
1029
  await repo.deleteEvent(id);
1032
- await eventDeleted(repo, before, { waitUntil: waitUntilFrom(ctx.locals) });
1030
+ await flushEventHooks(repo, hooksFor(ctx));
1033
1031
  return { ok: true };
1034
1032
  },
1035
1033
  }),
@@ -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';
@@ -169,9 +169,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
169
169
  if (!before) throw new ApiError(404, 'Event not found.');
170
170
  if (before.draft) await repo.applyEventDraft(before.id);
171
171
  await repo.updateEvent(before.id, { status: 'published', scheduledAt: null });
172
- const after = (await repo.getEvent(before.id))!;
173
- await eventChanged(repo, before, after, { origin: ctx.origin, waitUntil: ctx.waitUntil, applied: Boolean(before.draft) });
174
- return { ...summary(after) };
172
+ await flushEventChanges(repo, { origin: ctx.origin, waitUntil: ctx.waitUntil });
173
+ return { ...summary((await repo.getEvent(before.id))!) };
175
174
  }
176
175
  }
177
176
  }
@@ -9,6 +9,7 @@ import { eventImage } from './images';
9
9
  import { eventUrl } from './config';
10
10
  import { eventIsPublic, featuredOccurrence, formatOccurrence, locationLine, type Occurrence } from './events';
11
11
  import { collectViewEvents } from './data';
12
+ import type { ResolvedEventFilter } from './filter';
12
13
 
13
14
  export interface EventCardAttrs {
14
15
  mode: 'single' | 'upcoming';
@@ -56,7 +57,7 @@ export function eventCardHtml(
56
57
  return (
57
58
  `<article class="pc-ev-card${cancelled ? ' is-cancelled' : ''}">${media}<div class="pc-ev-card-body">` +
58
59
  `<div class="pc-ev-card-when">${cancelled ? `<span class="pc-ev-flag">${esc(eventStrings(siteLang(settings)).cancelled)}</span> ` : ''}${esc(when)}</div>` +
59
- `<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || 'Untitled event')}</a></${h}>` +
60
+ `<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || eventStrings(siteLang(settings)).calendar.untitled)}</a></${h}>` +
60
61
  (e.excerpt ? `<p class="pc-ev-card-excerpt">${esc(e.excerpt)}</p>` : '') +
61
62
  (loc ? `<div class="pc-ev-card-loc">${esc(loc)}</div>` : '') +
62
63
  cta +
@@ -65,18 +66,19 @@ export function eventCardHtml(
65
66
  }
66
67
 
67
68
  // next occurrence of each distinct event, soonest first; tag/excludeTag
68
- // narrow by event tag (name or slug, any case)
69
+ // narrow by event tag (name or slug, any case), and filter by an address's
70
+ // cal/tag/notag/room (filter.ts); locale: titles and links in that language
69
71
  export async function upcomingEvents(
70
72
  repo: ContentRepository,
71
73
  settings: Settings,
72
74
  now: number,
73
- opts: { calendarIds?: string[]; limit: number; horizonDays?: number; tag?: string; excludeTag?: string }
75
+ opts: { calendarIds?: string[]; limit: number; horizonDays?: number; tag?: string; excludeTag?: string; filter?: ResolvedEventFilter; locale?: string }
74
76
  ) {
75
77
  const tz = settings.timezone || 'UTC';
76
78
  const tagged = tagFilter(opts.tag, opts.excludeTag);
77
79
  const { events } = await collectViewEvents(repo, settings, {
78
80
  displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
79
- }, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false });
81
+ }, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false, filter: opts.filter, locale: opts.locale });
80
82
  const seen = new Set<string>();
81
83
  const out: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>[] = [];
82
84
  for (const v of events) {
@@ -98,13 +100,14 @@ export async function renderEventCards(a: EventCardAttrs, repo: ContentRepositor
98
100
  if (!o) return wrap('<p class="pc-ev-empty">This event isn’t available.</p>', ' is-empty');
99
101
  return wrap(eventCardHtml(o, settings, { showImage: a.showImage, showCta: true }), ' is-single');
100
102
  }
103
+ const noUpcoming = eventStrings(siteLang(settings)).calendar.noUpcoming;
101
104
  let calendarIds: string[] | undefined;
102
105
  if (a.calendar) {
103
106
  const c = await repo.getCalendarBySlug(a.calendar);
104
- if (!c || c.visibility === 'private') return wrap('<p class="pc-ev-empty">No upcoming events.</p>', ' is-empty');
107
+ if (!c || c.visibility === 'private') return wrap(`<p class="pc-ev-empty">${esc(noUpcoming)}</p>`, ' is-empty');
105
108
  calendarIds = [c.id];
106
109
  }
107
110
  const items = await upcomingEvents(repo, settings, now, { calendarIds, limit: a.limit });
108
- if (!items.length) return wrap('<p class="pc-ev-empty">No upcoming events.</p>', ' is-empty');
111
+ if (!items.length) return wrap(`<p class="pc-ev-empty">${esc(noUpcoming)}</p>`, ' is-empty');
109
112
  return wrap(items.map((o) => eventCardHtml(o, settings, { showImage: a.showImage })).join(''));
110
113
  }
@@ -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
+ ] };
@@ -7,6 +7,10 @@ import { roomColor, type CalEvent, type Calendar, type Resource, type Settings }
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';
10
+ import { occurrenceMatches, tagsMatch, untaggedMatches, type ResolvedEventFilter } from './filter';
11
+ import { eventStrings } from './event-words';
12
+ import { localePath } from '../i18n';
13
+ import { localizeDoc } from '../translations';
10
14
 
11
15
  export interface ViewEvent extends CalendarEvent {
12
16
  url?: string;
@@ -34,6 +38,12 @@ export interface ViewScope {
34
38
  includeFeeds?: boolean;
35
39
  // "Reserved" blocks from resources set to show on the calendar
36
40
  includeBookings?: boolean;
41
+ // the address's cal/tag/notag/room (filter.ts); its calendars narrow
42
+ // calendarIds when both are given
43
+ filter?: ResolvedEventFilter;
44
+ // the request's language (requestLocale): translated titles, event links
45
+ // under its /<locale> prefix, and "Reserved" in its words
46
+ locale?: string;
37
47
  }
38
48
 
39
49
  const pad = (n: number) => String(n).padStart(2, '0');
@@ -46,7 +56,9 @@ export async function collectViewEvents(
46
56
  ): Promise<{ events: ViewEvent[]; calendars: Calendar[]; errors: string[] }> {
47
57
  const all = await repo.listCalendars();
48
58
  const byId = new Map(all.map((c) => [c.id, c]));
49
- const requested = scope.calendarIds ? new Set(scope.calendarIds) : null;
59
+ const f = scope.filter;
60
+ const ids = scope.calendarIds && f?.calendarIds ? scope.calendarIds.filter((id) => f.calendarIds!.includes(id)) : scope.calendarIds ?? f?.calendarIds;
61
+ const requested = ids ? new Set(ids) : null;
50
62
  const visible = all.filter((c) => {
51
63
  if (scope.audience === 'admin') return true;
52
64
  // a directly requested calendar may be unlisted; private never is
@@ -60,6 +72,7 @@ export async function collectViewEvents(
60
72
  const source = scope.audience === 'admin' ? (await repo.listEvents()).filter((e) => e.status !== 'archived') : await repo.listPublishedEvents();
61
73
  const natives = source.filter((e) => {
62
74
  if (scope.audience === 'public' && !eventIsPublic(e, byId)) return false;
75
+ if (f && !tagsMatch(f, e.tags, { internal: f.internal })) return false;
63
76
  if (!e.calendarIds.length) {
64
77
  if (requested) return false;
65
78
  return scope.includeUncategorized ?? true;
@@ -73,39 +86,42 @@ export async function collectViewEvents(
73
86
  const shown = new Map<string, CalEvent>();
74
87
  const view = (e: CalEvent) => {
75
88
  if (scope.audience === 'admin') return e;
76
- if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(e));
89
+ if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(localizeDoc(e, scope.locale)));
77
90
  return shown.get(e.id)!;
78
91
  };
79
92
  const resources = natives.some((e) => e.roomHold.resourceIds.length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
80
93
  const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
81
94
  .filter((r): r is Resource => Boolean(r) && (scope.audience === 'admin' || r!.showOnEvents))
82
95
  .map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
96
+ const words = eventStrings(scope.locale).calendar;
83
97
  for (const o of expandEvents(natives, settings, win)) {
84
- const e = o.event;
98
+ // rooms are per date (a moved date can change them)
99
+ if (f?.roomIds && !occurrenceMatches(f, o)) continue;
100
+ const e = view(o.event);
85
101
  const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
86
102
  const p = tzParts(o.start, tz);
87
103
  const on = e.rrule ? `?on=${p.y}-${pad(p.m)}-${pad(p.d)}` : '';
88
104
  events.push({
89
105
  start: o.start, end: o.end, allDay: o.allDay, days: o.days,
90
- title: e.title || 'Untitled event',
106
+ title: e.title || words.untitled,
91
107
  location: [e.location.name, e.location.address].filter(Boolean).join(', '),
92
108
  description: e.excerpt,
93
109
  cal: cal ? idx.get(cal.id) ?? 0 : 0,
94
110
  ref: e.id,
95
- url: eventUrl(e.slug) + on,
111
+ url: localePath(eventUrl(e.slug), scope.locale ?? '') + on,
96
112
  color: cal ? safeColor(cal.color) : undefined,
97
113
  cancelled: o.cancelled,
98
114
  image: e.cover || undefined,
99
115
  imageAlt: e.coverAlt || undefined,
100
116
  calendarName: cal?.name,
101
- event: view(e),
117
+ event: e,
102
118
  rooms: rooms(o.roomIds),
103
119
  });
104
120
  }
105
121
 
106
122
  const errors: string[] = [];
107
123
  if (scope.includeFeeds ?? true) {
108
- const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl);
124
+ const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl && (!f || untaggedMatches(f, { calendarId: c.id })));
109
125
  const results = await Promise.all(
110
126
  feeds.map(async (c) => {
111
127
  try {
@@ -125,7 +141,7 @@ export async function collectViewEvents(
125
141
  // resources marked "show on calendar": confirmed bookings appear as
126
142
  // Reserved blocks (no guest names on public views) — only on unscoped views
127
143
  if (!requested && (scope.includeBookings ?? true) && CALENDAR.bookings) {
128
- const resources = (await repo.listResources()).filter((r) => r.showOnCalendar);
144
+ const resources = (await repo.listResources()).filter((r) => r.showOnCalendar && (!f || untaggedMatches(f, { roomId: r.id })));
129
145
  if (resources.length) {
130
146
  const booked = await repo.listBookings({ from: win.windowStartMs, to: win.windowEndMs, statuses: ['confirmed'], kinds: ['booking'] });
131
147
  const byId = new Map(resources.map((r) => [r.id, r]));
@@ -135,7 +151,7 @@ export async function collectViewEvents(
135
151
  const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
136
152
  events.push({
137
153
  start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
138
- title: scope.audience === 'admin' ? `${r.name} · ${b.name}` : `Reserved · ${r.name}`,
154
+ title: scope.audience === 'admin' ? `${r.name} · ${b.name}` : `${words.reserved} · ${r.name}`,
139
155
  location: r.name, description: '', cal: inScope.length, color: '#8e919a', calendarName: r.name,
140
156
  url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
141
157
  });