@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.
- package/CHANGELOG.md +28 -0
- package/cli/migrations.generated.mjs +117 -16
- package/docs/environment-sync.md +6 -0
- package/package.json +1 -1
- package/src/actions/index.ts +2 -4
- package/src/lib/calendar/agent.ts +3 -4
- package/src/lib/calendar/cards.ts +9 -6
- package/src/lib/calendar/change-migration.ts +65 -0
- package/src/lib/calendar/data.ts +25 -9
- package/src/lib/calendar/event-words.ts +124 -0
- package/src/lib/calendar/feeds.ts +11 -3
- package/src/lib/calendar/filter.ts +140 -0
- package/src/lib/calendar/hooks.ts +76 -31
- package/src/lib/calendar/render.ts +20 -16
- package/src/lib/calendar/requests.ts +2 -3
- package/src/lib/calendar/schedule.ts +4 -4
- package/src/lib/content-service.ts +15 -12
- package/src/lib/mobile/calendar.ts +27 -12
- package/src/lib/mobile/http.ts +5 -3
- package/src/lib/push/service.ts +1 -1
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +29 -0
- package/src/lib/repo/types.ts +21 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/sync/store.ts +4 -0
- package/src/middleware.ts +13 -2
- package/src/pages/admin/calendar/events/index.astro +4 -9
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +3 -3
- package/src/pages/api/calendar/events/index.ts +18 -12
- package/src/pages/api/webhooks/docs.ts +5 -0
- package/src/pages/calendar/[slug].ics.ts +8 -2
- package/src/pages/calendar/feed.ics.ts +9 -2
- package/src/pages/calendar/index.astro +104 -48
- package/src/pages/embed/calendar/[slug].astro +29 -9
- 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
|
|
87
|
+
const snapshot2 = (row) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
|
|
88
88
|
${kind === "post" ? `'collection',${row}.collection` : `'locale',${row}.locale,'isHome',json(CASE WHEN ${row}.is_home THEN 'true' ELSE 'false' END)`})`;
|
|
89
|
-
const
|
|
89
|
+
const changed2 = fields.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(" OR ");
|
|
90
90
|
const event = `CASE WHEN OLD.status!='published' THEN '${kind}.published'
|
|
91
91
|
WHEN NEW.status!='published' THEN '${kind}.unpublished' ELSE '${kind}.updated' END`;
|
|
92
92
|
return [
|
|
93
|
-
trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${
|
|
93
|
+
trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot2("NEW")})`),
|
|
94
94
|
trigger(
|
|
95
95
|
`webhook_${kind}_update`,
|
|
96
96
|
"UPDATE",
|
|
97
97
|
table,
|
|
98
|
-
`(OLD.status='published' OR NEW.status='published') AND (${
|
|
98
|
+
`(OLD.status='published' OR NEW.status='published') AND (${changed2})`,
|
|
99
99
|
event,
|
|
100
|
-
`json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${
|
|
101
|
-
ELSE json_set(${
|
|
102
|
-
'previous',json(CASE WHEN OLD.status='published' THEN ${
|
|
100
|
+
`json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${snapshot2("NEW")}
|
|
101
|
+
ELSE json_set(${snapshot2("OLD")},'$.status',NEW.status) END),
|
|
102
|
+
'previous',json(CASE WHEN OLD.status='published' THEN ${snapshot2("OLD")} ELSE 'null' END))`
|
|
103
103
|
),
|
|
104
|
-
trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${
|
|
104
|
+
trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot2("OLD")})`)
|
|
105
105
|
];
|
|
106
106
|
}
|
|
107
107
|
var media = (row) => `json_object('media',json_object('id',${row}.id,'key',${row}.key,'filename',${row}.filename,
|
|
@@ -218,6 +218,104 @@ var ROOM_HOLD_MIGRATION = { version: 41, statements: [
|
|
|
218
218
|
`ALTER TABLE events ADD COLUMN room_hold TEXT NOT NULL DEFAULT '{}'`
|
|
219
219
|
] };
|
|
220
220
|
|
|
221
|
+
// src/lib/calendar/change-migration.ts
|
|
222
|
+
var now4 = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
|
|
223
|
+
var EVENT_CHANGE_SNAPSHOT = [
|
|
224
|
+
"id",
|
|
225
|
+
"slug",
|
|
226
|
+
"title",
|
|
227
|
+
"excerpt",
|
|
228
|
+
"cover",
|
|
229
|
+
"cover_alt",
|
|
230
|
+
"status",
|
|
231
|
+
"featured",
|
|
232
|
+
"tags",
|
|
233
|
+
"starts",
|
|
234
|
+
"ends",
|
|
235
|
+
"all_day",
|
|
236
|
+
"timezone",
|
|
237
|
+
"rrule",
|
|
238
|
+
"exdates",
|
|
239
|
+
"cancelled",
|
|
240
|
+
"cancelled_dates",
|
|
241
|
+
"location",
|
|
242
|
+
"cta",
|
|
243
|
+
"calendar_ids",
|
|
244
|
+
"fields",
|
|
245
|
+
"rsvp",
|
|
246
|
+
"room_hold",
|
|
247
|
+
"start_at",
|
|
248
|
+
"end_at",
|
|
249
|
+
"published_at",
|
|
250
|
+
"scheduled_at",
|
|
251
|
+
"created_at",
|
|
252
|
+
"updated_at"
|
|
253
|
+
];
|
|
254
|
+
var EVENT_CHANGE_COLUMNS = [
|
|
255
|
+
"slug",
|
|
256
|
+
"title",
|
|
257
|
+
"body_json",
|
|
258
|
+
"body_html",
|
|
259
|
+
"excerpt",
|
|
260
|
+
"cover",
|
|
261
|
+
"cover_alt",
|
|
262
|
+
"seo_title",
|
|
263
|
+
"seo_desc",
|
|
264
|
+
"status",
|
|
265
|
+
"featured",
|
|
266
|
+
"tags",
|
|
267
|
+
"template",
|
|
268
|
+
"starts",
|
|
269
|
+
"ends",
|
|
270
|
+
"all_day",
|
|
271
|
+
"timezone",
|
|
272
|
+
"rrule",
|
|
273
|
+
"exdates",
|
|
274
|
+
"cancelled",
|
|
275
|
+
"cancelled_dates",
|
|
276
|
+
"location",
|
|
277
|
+
"cta",
|
|
278
|
+
"calendar_ids",
|
|
279
|
+
"fields",
|
|
280
|
+
"rsvp",
|
|
281
|
+
"translations",
|
|
282
|
+
"room_hold",
|
|
283
|
+
"start_at",
|
|
284
|
+
"end_at"
|
|
285
|
+
];
|
|
286
|
+
var snapshot = (row) => `json_object(${EVENT_CHANGE_SNAPSHOT.map((c) => `'${c}',${row}.${c}`).join(",")})`;
|
|
287
|
+
var live = "(SELECT suppressed FROM webhook_control WHERE id=1)=0";
|
|
288
|
+
var quiet = "COALESCE((SELECT quiet FROM push_control WHERE id=1),0)";
|
|
289
|
+
var record = (id, before, after) => `INSERT INTO event_changes (event_id,old_row,new_row,quiet,created_at)
|
|
290
|
+
VALUES (${id},${before},${after},${quiet},${now4});`;
|
|
291
|
+
var changed = EVENT_CHANGE_COLUMNS.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
|
|
292
|
+
var skipped = `CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 OR ${quiet}=1 THEN 'skipped' ELSE 'pending' END`;
|
|
293
|
+
var ledger = `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
|
|
294
|
+
SELECT 'event:'||NEW.id,'event',NEW.id,NULL,${skipped},NULL,${now4},${now4}
|
|
295
|
+
WHERE NOT EXISTS (SELECT 1 FROM push_queue WHERE key='event:'||NEW.id);`;
|
|
296
|
+
var EVENT_CHANGES_MIGRATION = { version: 65, statements: [
|
|
297
|
+
`CREATE TABLE event_changes (
|
|
298
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL, old_row TEXT, new_row TEXT,
|
|
299
|
+
quiet INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, claimed_until INTEGER NOT NULL DEFAULT 0,
|
|
300
|
+
created_at INTEGER NOT NULL
|
|
301
|
+
)`,
|
|
302
|
+
`CREATE INDEX event_changes_event ON event_changes (event_id, claimed_until)`,
|
|
303
|
+
`CREATE TABLE push_control (id INTEGER PRIMARY KEY CHECK(id=1), quiet INTEGER NOT NULL DEFAULT 0)`,
|
|
304
|
+
`INSERT INTO push_control (id,quiet) VALUES (1,0)`,
|
|
305
|
+
`CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
|
|
306
|
+
BEGIN ${record("NEW.id", "NULL", snapshot("NEW"))} END`,
|
|
307
|
+
`CREATE TRIGGER event_changes_update AFTER UPDATE ON events
|
|
308
|
+
WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
|
|
309
|
+
BEGIN ${record("NEW.id", snapshot("OLD"), snapshot("NEW"))} END`,
|
|
310
|
+
`CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
|
|
311
|
+
BEGIN ${record("OLD.id", snapshot("OLD"), "NULL")} END`,
|
|
312
|
+
`DROP TRIGGER push_event_insert`,
|
|
313
|
+
`DROP TRIGGER push_event_publish`,
|
|
314
|
+
`CREATE TRIGGER push_event_insert AFTER INSERT ON events WHEN NEW.status='published' BEGIN ${ledger} END`,
|
|
315
|
+
`CREATE TRIGGER push_event_publish AFTER UPDATE OF status ON events
|
|
316
|
+
WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${ledger} END`
|
|
317
|
+
] };
|
|
318
|
+
|
|
221
319
|
// src/lib/maps/migration.ts
|
|
222
320
|
var PLACES_MIGRATION = { version: 43, statements: [
|
|
223
321
|
`CREATE TABLE IF NOT EXISTS places (
|
|
@@ -468,8 +566,8 @@ var CONTENT_VERSION_COLUMNS = {
|
|
|
468
566
|
};
|
|
469
567
|
var BUMP_CONTENT_VERSION = "UPDATE settings SET content_version = content_version + 1 WHERE id = 1;";
|
|
470
568
|
function contentVersionTriggers(table, columns) {
|
|
471
|
-
const
|
|
472
|
-
const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${
|
|
569
|
+
const changed2 = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
|
|
570
|
+
const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${changed2} BEGIN ${BUMP_CONTENT_VERSION} END`;
|
|
473
571
|
if (table === "settings") return [update];
|
|
474
572
|
return [
|
|
475
573
|
`CREATE TRIGGER content_version_${table}_insert AFTER INSERT ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
|
|
@@ -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
|
|
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 =
|
|
1287
|
+
const deadline = now5() + (opts.waitMs ?? 6e4);
|
|
1187
1288
|
let waited = false;
|
|
1188
1289
|
for (; ; ) {
|
|
1189
|
-
const t =
|
|
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, ?)", [
|
|
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,
|
|
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) };
|
package/docs/environment-sync.md
CHANGED
|
@@ -221,6 +221,12 @@ This publishes only the draft-capable items in that run. It rejects later edits
|
|
|
221
221
|
to those items instead of implicitly publishing somebody else's changes.
|
|
222
222
|
Design/layout changes conservatively share a settings guard.
|
|
223
223
|
|
|
224
|
+
What a run publishes or changes sends the destination's webhooks, as an edit
|
|
225
|
+
there would (`post.*`, `page.*`, `event.*`). It never sends app push
|
|
226
|
+
notifications for events: an event a run publishes counts as already
|
|
227
|
+
announced, and its date, time or place changes send no "When an event changes"
|
|
228
|
+
notice. Posts a run publishes still get the "new post" notice.
|
|
229
|
+
|
|
224
230
|
## History and rollback
|
|
225
231
|
|
|
226
232
|
```sh
|
package/package.json
CHANGED
package/src/actions/index.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
173
|
-
|
|
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 ||
|
|
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(
|
|
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(
|
|
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
|
+
] };
|
package/src/lib/calendar/data.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ||
|
|
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:
|
|
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}` :
|
|
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
|
});
|