@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
|
@@ -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
|
}
|
|
@@ -174,6 +174,9 @@ export const ResourceInput = z.object({
|
|
|
174
174
|
timezone: z.string().max(64).refine((t) => !t || isValidTimeZone(t), 'Unknown timezone.').optional(),
|
|
175
175
|
hours: Hours.optional(), overrides: z.array(Override).max(200).optional(),
|
|
176
176
|
visibility: z.enum(['public', 'unlisted']).optional(), showOnCalendar: z.boolean().optional(), placeId: z.string().max(100).optional(),
|
|
177
|
+
// '' = no colour; public event data names it only with showOnEvents; requestable answers GET /api/v1/rooms/free
|
|
178
|
+
color: z.union([z.literal(''), z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a colour like #2f6fde.')]).optional(),
|
|
179
|
+
showOnEvents: z.boolean().optional(), requestable: z.boolean().optional(),
|
|
177
180
|
// ICS / Google calendars whose events block this room (fetched like calendar feeds)
|
|
178
181
|
busyFeeds: z.array(z.string().max(2000)).max(10).optional(),
|
|
179
182
|
notifyEmails: z.array(z.string().trim().email().max(200)).max(10).optional(),
|
|
@@ -3,9 +3,12 @@ import type { ContentRepository } from '../repo/types';
|
|
|
3
3
|
import { publicTags, tagFilter, 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
|
-
import { calendarToJson, eventToJson } from '../calendar/api';
|
|
7
|
-
import { eventIsListed, eventIsPublic, expandEvents, featuredOccurrence } from '../calendar/events';
|
|
6
|
+
import { calendarToJson, eventToJson, roomsToJson } from '../calendar/api';
|
|
7
|
+
import { eventIsListed, eventIsPublic, expandEvents, featuredOccurrence, type Occurrence } from '../calendar/events';
|
|
8
8
|
import { availabilityContext } from '../booking/service';
|
|
9
|
+
import { roomBusy } from '../booking/holds';
|
|
10
|
+
import { sha256Hex } from '../api-keys';
|
|
11
|
+
import type { PublicContext } from './route';
|
|
9
12
|
import { addDaysYmd, describeLink, linkLengths, localMidnight, slotsForDay, ymd } from '../booking/availability';
|
|
10
13
|
import { siteOrigin } from '../seo';
|
|
11
14
|
import { safeUrl } from './content';
|
|
@@ -32,7 +35,7 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
32
35
|
const q = eventsQuery.parse(queryParams(url));
|
|
33
36
|
const locale = mobileLocale(q.locale);
|
|
34
37
|
const term = q.q?.trim() ?? '';
|
|
35
|
-
const [settings, calendars, events, indexed] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(),
|
|
38
|
+
const [settings, calendars, events, resources, indexed] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), roomMap(repo),
|
|
36
39
|
// the search index's matches (body text, word prefixes), so "more" from /search continues here
|
|
37
40
|
term ? repo.searchPublished(term, { limit: 200, collections: [] }).then((r) => new Set(r.events.map(({ doc }) => doc.id))) : null]);
|
|
38
41
|
// days are the site's calendar days (a New York site's "today" starts at midnight there, not UTC)
|
|
@@ -58,9 +61,8 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
58
61
|
return {
|
|
59
62
|
locale, timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
|
|
60
63
|
occurrences: occurrences.slice(start, start + q.limit).map(o => ({
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
event: publicEventDto(o.event, origin, publicCalendars, settings, locale),
|
|
64
|
+
...occurrenceDto(o, resources),
|
|
65
|
+
event: publicEventDto(o.event, origin, publicCalendars, settings, locale, resources),
|
|
64
66
|
})),
|
|
65
67
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
66
68
|
};
|
|
@@ -74,7 +76,7 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
|
|
|
74
76
|
requireCalendar();
|
|
75
77
|
const q = eventDetailQuery.parse(queryParams(url));
|
|
76
78
|
const locale = mobileLocale(q.locale);
|
|
77
|
-
const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
79
|
+
const [settings, calendars, resources] = await Promise.all([repo.getSettings(), repo.listCalendars(), roomMap(repo)]);
|
|
78
80
|
const found = ref && ref.length <= 200 ? await repo.getEventBySlug(ref) ?? await repo.getEvent(ref) : null;
|
|
79
81
|
const all = new Map(calendars.map(c => [c.id, c]));
|
|
80
82
|
if (!found || !eventIsPublic(found, all)) throw new ApiError(404, 'Event not found.');
|
|
@@ -83,13 +85,20 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
|
|
|
83
85
|
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public').map(c => [c.id, c]));
|
|
84
86
|
return {
|
|
85
87
|
locale,
|
|
86
|
-
occurrence: o ?
|
|
87
|
-
event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale),
|
|
88
|
+
occurrence: o ? occurrenceDto(o, resources) : null,
|
|
89
|
+
event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale, resources),
|
|
88
90
|
};
|
|
89
91
|
}
|
|
92
|
+
/** every room by id, for naming the public ones (Resource.showOnEvents) */
|
|
93
|
+
export const roomMap = async (repo: ContentRepository) => new Map((await repo.listResources()).map((r) => [r.id, r]));
|
|
94
|
+
// one date: its time, whether it's cancelled, and the public rooms it holds
|
|
95
|
+
export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>) {
|
|
96
|
+
return { id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, cancelled: o.cancelled,
|
|
97
|
+
rooms: roomsToJson(o.roomIds, resources) };
|
|
98
|
+
}
|
|
90
99
|
// e is already localized; publicCalendars limits which calendars are named
|
|
91
|
-
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null) {
|
|
92
|
-
const event = eventToJson(e, origin, publicCalendars, settings.timezone);
|
|
100
|
+
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null, resources?: Map<string, Resource>) {
|
|
101
|
+
const event = eventToJson(e, origin, publicCalendars, settings.timezone, { resources });
|
|
93
102
|
event.url = localizedUrl(event.url, origin, locale);
|
|
94
103
|
// Only configured public event fields; no undeclared imported metadata, no private fields.
|
|
95
104
|
event.fields = publicEventFields(e.fields);
|
|
@@ -137,3 +146,32 @@ export async function availabilityFor(repo: ContentRepository, resource: Resourc
|
|
|
137
146
|
})),
|
|
138
147
|
};
|
|
139
148
|
}
|
|
149
|
+
|
|
150
|
+
// Free or busy for one room over a span, for people asking before they
|
|
151
|
+
// request a time (a request form, the app). Only rooms set as requestable
|
|
152
|
+
// answer (others are "not found"), and only the busy times come back, never
|
|
153
|
+
// what or who: event holds with their setup and cleanup, bookings, blocks and
|
|
154
|
+
// the room's busy calendars, merged. A day is the site's midnight; a
|
|
155
|
+
// date-time is that instant. 30 checks a minute per address.
|
|
156
|
+
export const roomFreeQuery = z.object({ room: z.string().min(1).max(200), start: isoDayOrInstant, end: isoDayOrInstant }).strict();
|
|
157
|
+
export async function roomFree(c: PublicContext) {
|
|
158
|
+
requireCalendar(true);
|
|
159
|
+
const q = roomFreeQuery.parse(queryParams(c.url));
|
|
160
|
+
if (!await c.repo.email.rateLimit(`rooms:free:${await sha256Hex(c.address)}`, 30, 60_000)) throw new ApiError(429, 'Too many checks. Try again in a minute.');
|
|
161
|
+
const settings = await c.repo.getSettings();
|
|
162
|
+
const tz = settings.timezone || 'UTC';
|
|
163
|
+
const at = (v: string) => (v.length === 10 ? localMidnight(v, tz) : Date.parse(v));
|
|
164
|
+
const start = at(q.start), end = at(q.end);
|
|
165
|
+
if (end <= start || end - start > 31 * 86_400_000 + 3_600_000) throw new ApiError(400, 'Use a span of up to 31 days that ends after it starts.');
|
|
166
|
+
const room = await c.repo.getResourceBySlug(q.room);
|
|
167
|
+
if (!room || !room.requestable) throw new ApiError(404, 'Room not found.');
|
|
168
|
+
const busy: { start: number; end: number }[] = [];
|
|
169
|
+
for (const b of await roomBusy(c.repo, settings, { resourceIds: [room.id], from: start, to: end })) {
|
|
170
|
+
const s = Math.max(b.start, start), e = Math.min(b.end, end);
|
|
171
|
+
const last = busy[busy.length - 1];
|
|
172
|
+
if (last && s <= last.end) last.end = Math.max(last.end, e);
|
|
173
|
+
else busy.push({ start: s, end: e });
|
|
174
|
+
}
|
|
175
|
+
const iso = (n: number) => new Date(n).toISOString();
|
|
176
|
+
return { room: { slug: room.slug, name: room.name }, start: iso(start), end: iso(end), free: !busy.length, busy: busy.map((b) => ({ start: iso(b.start), end: iso(b.end) })) };
|
|
177
|
+
}
|
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
3
3
|
import type { ContentRepository } from '../repo/types';
|
|
4
4
|
import { contentQuery, contentDetailQuery } from './content';
|
|
5
|
-
import { eventsQuery, eventDetailQuery, availabilityQuery } from './calendar';
|
|
5
|
+
import { eventsQuery, eventDetailQuery, availabilityQuery, roomFreeQuery } from './calendar';
|
|
6
6
|
import { bookingInput } from './bookings';
|
|
7
7
|
import { rsvpQuery } from './rsvp';
|
|
8
8
|
import { pagesQuery } from './pages';
|
|
@@ -109,6 +109,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
109
109
|
description: `${manageNote} Past its holdUntil the spot was released (410) and goes to the next person in line; a booking with no held spot answers 409.` }), ...cors };
|
|
110
110
|
paths['/api/v1/bookings/manage/availability'] = { get: priv('Open times for moving the booking (its own time counts as free)', ref('Availability'), { header: bookingToken, query: manageAvailabilityQuery, description: manageNote }), ...cors };
|
|
111
111
|
paths['/api/v1/bookings/manage/reschedule'] = { post: priv('Move the booking to a new start (room bookings, not RSVPs)', ref('ManagedBooking'), { header: bookingToken, body: rescheduleInput, write: true, description: manageNote }), ...cors };
|
|
112
|
+
paths['/api/v1/rooms/free'] = { get: priv('Free or busy for one room over a span, without names; requires bookings', ref('RoomFree'), { query: roomFreeQuery,
|
|
113
|
+
description: 'Only rooms marked “Anyone can check if it’s free” answer; any other slug is 404. start and end are a site-local day (its midnight) or an ISO date-time with a zone; at most 31 days. busy lists the taken times inside the span, merged and clipped: events holding the room (with their setup and cleanup), bookings, staff blocks and the room’s busy calendars, never what or who. Never cached. 30 checks per minute per client address (429).' }), ...cors };
|
|
112
114
|
paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
|
|
113
115
|
responses: { ...errors, 200: { description: 'An iCalendar file.', content: { 'text/calendar': { schema: { type: 'string' } } } } } }, ...cors };
|
|
114
116
|
paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
|
|
@@ -27,23 +27,26 @@ export const Page = ContentSummary.extend({
|
|
|
27
27
|
kind: z.literal('page'), body: Html, cover: str, seo: z.object({ title: str, description: str }),
|
|
28
28
|
mode: z.enum(['content', 'sections']), sections: z.array(Section),
|
|
29
29
|
});
|
|
30
|
+
// a room an event (or one date) holds, named only when set to show on public events; color null = none
|
|
31
|
+
export const EventRoom = z.object({ slug: str, name: str, color: str.nullable() });
|
|
30
32
|
export const Event = CalEvent.pick({
|
|
31
33
|
id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
|
|
32
34
|
allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
|
|
33
35
|
}).extend({ rsvp: CalEvent.shape.rsvp.removeDefault().omit({ notifyStaff: true }).default({}).optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
|
|
34
36
|
calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
|
|
37
|
+
rooms: z.array(EventRoom),
|
|
35
38
|
// push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
|
|
36
39
|
notify: z.object({ changes: strings }),
|
|
37
40
|
});
|
|
38
41
|
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
39
|
-
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, event: Event })),
|
|
42
|
+
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom), event: Event })),
|
|
40
43
|
});
|
|
41
44
|
const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
|
|
42
45
|
const ContentHit = ContentSummary.extend({ snippet: Snippet });
|
|
43
|
-
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event });
|
|
46
|
+
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom) }).nullable(), event: Event });
|
|
44
47
|
export const SearchResults = z.object({ locale, query: str,
|
|
45
48
|
posts: z.array(ContentHit), pages: z.array(ContentHit),
|
|
46
|
-
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event })),
|
|
49
|
+
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom) }).nullable(), event: Event })),
|
|
47
50
|
more: z.object({ posts: bool, pages: bool, events: bool }),
|
|
48
51
|
});
|
|
49
52
|
const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
|
|
@@ -114,6 +117,8 @@ export const Resources = z.object({ pagination: Pagination, resources: z.array(z
|
|
|
114
117
|
askPhone: bool, autoConfirm: bool, url: str, questions: z.array(BookingQuestion.omit({ help: true })),
|
|
115
118
|
})),
|
|
116
119
|
})) });
|
|
120
|
+
// GET /rooms/free: free = nothing takes the room in [start, end); busy = the taken times, no names
|
|
121
|
+
export const RoomFree = z.object({ room: z.object({ slug: str, name: str }), start: date, end: date, free: bool, busy: z.array(z.object({ start: date, end: date })) });
|
|
117
122
|
export const Availability = z.object({ timezone: str, mode: z.enum(['slots', 'day', 'range']), lengths: z.array(z.number()), autoConfirm: bool,
|
|
118
123
|
days: z.array(z.object({ date: str, slots: z.array(z.object({ start: date, end: date, minutes: z.number() })) })),
|
|
119
124
|
});
|
package/src/lib/mobile/search.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { eventIsListed, featuredOccurrence } from '../calendar/events';
|
|
|
9
9
|
import { localizeDoc } from '../translations';
|
|
10
10
|
import { ApiError } from '../api';
|
|
11
11
|
import { contentDto } from './content';
|
|
12
|
-
import { publicEventDto } from './calendar';
|
|
12
|
+
import { occurrenceDto, publicEventDto, roomMap } from './calendar';
|
|
13
13
|
import { queryParams } from './http';
|
|
14
14
|
import { localeQuery, mobileLocale } from './locale';
|
|
15
15
|
|
|
@@ -56,9 +56,9 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
56
56
|
const q = searchQuery.parse(queryParams(url));
|
|
57
57
|
if (q.collection && !Object.hasOwn(CONTENT.collections, q.collection)) throw new ApiError(400, 'Unknown collection.');
|
|
58
58
|
const locale = mobileLocale(q.locale);
|
|
59
|
-
const [found, settings, calendars] = await Promise.all([
|
|
59
|
+
const [found, settings, calendars, resources] = await Promise.all([
|
|
60
60
|
repo.searchPublished(q.q, { locale: normalizePageLocale(locale ?? ''), limit: q.limit + 1, collections: q.collection ? [q.collection] : Object.keys(CONTENT.collections) }),
|
|
61
|
-
repo.getSettings(), repo.listCalendars(),
|
|
61
|
+
repo.getSettings(), repo.listCalendars(), roomMap(repo),
|
|
62
62
|
]);
|
|
63
63
|
const origin = siteOrigin(settings, url);
|
|
64
64
|
const want = new Set(q.types);
|
|
@@ -75,7 +75,6 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
75
75
|
events = deeper.events.filter(({ doc }) => eventIsListed(doc, all));
|
|
76
76
|
}
|
|
77
77
|
const now = Date.now();
|
|
78
|
-
const iso = (n: number) => new Date(n).toISOString();
|
|
79
78
|
return {
|
|
80
79
|
locale, query: q.q,
|
|
81
80
|
posts: posts.slice(0, q.limit).map(({ doc, snippet }) => ({ ...contentDto(doc, 'post', origin, false, locale), snippet: plainSnippet(snippet) })),
|
|
@@ -86,8 +85,8 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
86
85
|
return {
|
|
87
86
|
snippet: plainSnippet(snippet),
|
|
88
87
|
// the next occurrence, or the latest one when the event is over
|
|
89
|
-
occurrence: o ? {
|
|
90
|
-
event: publicEventDto(e, origin, listed, settings, locale),
|
|
88
|
+
occurrence: o ? (({ id: _, ...rest }) => rest)(occurrenceDto(o, resources)) : null,
|
|
89
|
+
event: publicEventDto(e, origin, listed, settings, locale, resources),
|
|
91
90
|
};
|
|
92
91
|
}),
|
|
93
92
|
more: { posts: posts.length > q.limit, pages: pages.length > q.limit, events: events.length > q.limit },
|
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';
|
|
@@ -710,6 +711,19 @@ export const MIGRATIONS: Migration[] = [
|
|
|
710
711
|
{ version: 63, statements: [
|
|
711
712
|
`ALTER TABLE forms ADD COLUMN alerts TEXT NOT NULL DEFAULT '{}'`,
|
|
712
713
|
] },
|
|
714
|
+
// rooms: a colour and whether public event data names them
|
|
715
|
+
// (Resource.color / .showOnEvents, both visible), and whether anyone may ask
|
|
716
|
+
// if they're free (Resource.requestable, never shown, so no cache bump)
|
|
717
|
+
{ version: 64, statements: [
|
|
718
|
+
`ALTER TABLE resources ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
|
|
719
|
+
`ALTER TABLE resources ADD COLUMN show_on_events INTEGER NOT NULL DEFAULT 0`,
|
|
720
|
+
`ALTER TABLE resources ADD COLUMN requestable INTEGER NOT NULL DEFAULT 0`,
|
|
721
|
+
`DROP TRIGGER content_version_resources_update`,
|
|
722
|
+
contentVersionTriggers('resources', [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'])[2],
|
|
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,
|
|
713
727
|
];
|
|
714
728
|
|
|
715
729
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
|
@@ -717,6 +731,7 @@ export const CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
|
717
731
|
...CONTENT_VERSION_COLUMNS,
|
|
718
732
|
settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
|
|
719
733
|
events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
|
|
734
|
+
resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'],
|
|
720
735
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
|
|
721
736
|
};
|
|
722
737
|
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { LiveConfig } from '../live/types';
|
|
|
13
13
|
import { ApiError } from '../api';
|
|
14
14
|
import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
|
|
15
15
|
import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
|
|
16
|
-
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
16
|
+
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
17
17
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
18
18
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
19
19
|
import { cleanRrule } from '../calendar/rrule';
|
|
@@ -172,6 +172,7 @@ function rowToResource(r: Row): Resource {
|
|
|
172
172
|
amenities: json(r.amenities, []), timezone: r.timezone ?? '', hours: json(r.hours, {}), overrides: json(r.overrides, []),
|
|
173
173
|
busyFeeds: json(r.busy_feeds, []), notifyEmails: json(r.notify_emails, []), visibility: r.visibility ?? 'public',
|
|
174
174
|
showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', placeId: r.place_id ?? '', order: Number(r.ord ?? 0),
|
|
175
|
+
color: r.color ?? '', showOnEvents: Boolean(r.show_on_events), requestable: Boolean(r.requestable),
|
|
175
176
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
176
177
|
});
|
|
177
178
|
}
|
|
@@ -1029,6 +1030,33 @@ export class SqlRepository implements ContentRepository {
|
|
|
1029
1030
|
if (patch.live) await this.syncEventIndex(id);
|
|
1030
1031
|
}
|
|
1031
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
|
+
|
|
1032
1060
|
// ---- places ----
|
|
1033
1061
|
private placeInsert(p: Place): SqlStatement {
|
|
1034
1062
|
return {
|
|
@@ -1083,10 +1111,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1083
1111
|
private resourceInsert(r: Resource): SqlStatement {
|
|
1084
1112
|
return {
|
|
1085
1113
|
sql: `INSERT INTO resources (id, slug, name, kind, description, photo, capacity, amenities, timezone, hours, overrides, busy_feeds,
|
|
1086
|
-
notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1114
|
+
notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id, color, show_on_events, requestable) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1087
1115
|
args: [r.id, r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
|
|
1088
1116
|
JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
|
|
1089
|
-
r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId],
|
|
1117
|
+
r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0],
|
|
1090
1118
|
};
|
|
1091
1119
|
}
|
|
1092
1120
|
private linkInsert(l: BookingLink): SqlStatement {
|
|
@@ -1137,10 +1165,11 @@ export class SqlRepository implements ContentRepository {
|
|
|
1137
1165
|
const r = Resource.parse({ ...cur, ...data, id, updatedAt: Date.now() });
|
|
1138
1166
|
await this.driver.run(
|
|
1139
1167
|
`UPDATE resources SET slug = ?, name = ?, kind = ?, description = ?, photo = ?, capacity = ?, amenities = ?, timezone = ?, hours = ?,
|
|
1140
|
-
overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, place_id = ?,
|
|
1168
|
+
overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, place_id = ?,
|
|
1169
|
+
color = ?, show_on_events = ?, requestable = ?, updated_at = ? WHERE id = ?`,
|
|
1141
1170
|
[r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
|
|
1142
1171
|
JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
|
|
1143
|
-
r.feedToken, r.placeId, r.updatedAt, id]
|
|
1172
|
+
r.feedToken, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0, r.updatedAt, id]
|
|
1144
1173
|
);
|
|
1145
1174
|
}
|
|
1146
1175
|
async reorderResources(ids: string[]) {
|
|
@@ -1864,6 +1893,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
1864
1893
|
// Suppression and re-enabling share the restore transaction. A failed
|
|
1865
1894
|
// restore cannot leave notifications disabled or emit phantom deletes.
|
|
1866
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' },
|
|
1867
1898
|
{ sql: "UPDATE auth_invitations SET state='revoked' WHERE state<>'accepted'" },
|
|
1868
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) => ({
|
|
1869
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/schema.ts
CHANGED
|
@@ -509,6 +509,14 @@ export const Resource = z.object({
|
|
|
509
509
|
visibility: z.enum(['public', 'unlisted']).default('public'),
|
|
510
510
|
// confirmed bookings show on the public calendar as "Reserved" blocks
|
|
511
511
|
showOnCalendar: z.boolean().default(false),
|
|
512
|
+
// a #rrggbb colour for it on calendars and screens ('' = none; roomColor() reads it)
|
|
513
|
+
color: z.string().default(''),
|
|
514
|
+
// named in public event data (event pages, /calendar, the app API's rooms);
|
|
515
|
+
// off = only staff see that an event uses it
|
|
516
|
+
showOnEvents: z.boolean().default(false),
|
|
517
|
+
// anyone may ask whether it's free at a time (GET /api/v1/rooms/free: free or
|
|
518
|
+
// busy, never what or who)
|
|
519
|
+
requestable: z.boolean().default(false),
|
|
512
520
|
// secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
|
|
513
521
|
feedToken: z.string().default(''),
|
|
514
522
|
// the saved place this room is in ('' = none)
|
|
@@ -518,6 +526,8 @@ export const Resource = z.object({
|
|
|
518
526
|
updatedAt: z.number(),
|
|
519
527
|
});
|
|
520
528
|
export type Resource = z.infer<typeof Resource>;
|
|
529
|
+
/** A room's colour as #rrggbb, or '' when it has none (anything else stored reads as none). */
|
|
530
|
+
export const roomColor = (r: Pick<Resource, 'color'>) => (/^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '');
|
|
521
531
|
|
|
522
532
|
// A saved place (HQ, the park pavilion…): picked in an event's Where field,
|
|
523
533
|
// it copies its details into the event. Rooms can belong to one (resource.placeId).
|
package/src/lib/sync/catalog.ts
CHANGED
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
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import Admin from '../../../../layouts/Admin.astro';
|
|
3
3
|
import HoursEditor from '../../../../components/booking/HoursEditor.astro';
|
|
4
4
|
import { getRepo } from '../../../../lib/repo';
|
|
5
|
-
import { slugify } from '../../../../lib/schema';
|
|
5
|
+
import { roomColor, slugify } from '../../../../lib/schema';
|
|
6
6
|
import { bookingLinkUrl, bookingsEnabled, CALENDAR, resourceFeedUrl, resourceUrl } from '../../../../lib/calendar/config';
|
|
7
7
|
import { hoursFromForm, int, list, overridesFromForm } from '../../../../lib/booking/forms';
|
|
8
8
|
import { describeLink } from '../../../../lib/booking/availability';
|
|
@@ -53,6 +53,9 @@ if (Astro.request.method === 'POST') {
|
|
|
53
53
|
timezone: tz && isValidTimeZone(tz) ? tz : '',
|
|
54
54
|
visibility: form.get('visibility') === 'unlisted' ? 'unlisted' : 'public',
|
|
55
55
|
showOnCalendar: Boolean(form.get('showOnCalendar')),
|
|
56
|
+
color: form.get('noColor') ? '' : roomColor({ color: String(form.get('color') || '') }),
|
|
57
|
+
showOnEvents: Boolean(form.get('showOnEvents')),
|
|
58
|
+
requestable: Boolean(form.get('requestable')),
|
|
56
59
|
placeId: places.some((p) => p.id === form.get('placeId')) ? String(form.get('placeId')) : '',
|
|
57
60
|
hours: hoursFromForm(form), overrides: overridesFromForm(form), busyFeeds, notifyEmails: emails,
|
|
58
61
|
});
|
|
@@ -182,6 +185,15 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
|
|
|
182
185
|
</select>
|
|
183
186
|
</label>
|
|
184
187
|
<label class="check-row"><input type="checkbox" name="showOnCalendar" checked={resource.showOnCalendar} /> Show confirmed bookings on the public calendar as “Reserved”</label>
|
|
188
|
+
<div class="field">
|
|
189
|
+
<span>Colour <em class="hint">for events in it, on calendars and screens</em></span>
|
|
190
|
+
<span class="res-color">
|
|
191
|
+
<input type="color" name="color" value={roomColor(resource) || '#4f46e5'} aria-label="Colour" />
|
|
192
|
+
<label class="check-row"><input type="checkbox" name="noColor" checked={!roomColor(resource)} /> No colour</label>
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
<label class="check-row"><input type="checkbox" name="showOnEvents" checked={resource.showOnEvents} /> Name it on public events (event pages, the calendar and the app)</label>
|
|
196
|
+
<label class="check-row"><input type="checkbox" name="requestable" checked={resource.requestable} /> Anyone can check if it’s free (only free or busy, never what’s on)</label>
|
|
185
197
|
</section>
|
|
186
198
|
<section class="card card-pad">
|
|
187
199
|
<div class="card-head"><b>Busy calendars</b></div>
|