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