@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
|
@@ -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
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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)
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
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);
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/lib/push/service.ts
CHANGED
|
@@ -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
|
|
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.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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}`,
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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.
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -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)
|
package/src/lib/sync/store.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
130
|
-
if (
|
|
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 {
|
|
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
|
|
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 {
|
|
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¬ag=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,
|
|
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
|
|
53
|
-
|
|
54
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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 })
|
|
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
|
|
22
|
-
const
|
|
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¬ag=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
|
|
13
|
-
const
|
|
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
|
};
|