@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/cli/main.mjs +3 -2
  3. package/cli/migrations.generated.mjs +128 -16
  4. package/docs/calendar.md +112 -0
  5. package/docs/environment-sync.md +6 -0
  6. package/docs/mobile.md +1 -0
  7. package/docs/server.md +47 -2
  8. package/integration.mjs +2 -0
  9. package/package.json +1 -1
  10. package/src/actions/index.ts +16 -8
  11. package/src/components/calendar/QuickAdd.tsx +1 -1
  12. package/src/components/calendar/RoomHolds.tsx +23 -5
  13. package/src/lib/booking/holds.ts +98 -2
  14. package/src/lib/booking/service.ts +2 -1
  15. package/src/lib/calendar/agent.ts +5 -5
  16. package/src/lib/calendar/api.ts +16 -3
  17. package/src/lib/calendar/change-migration.ts +65 -0
  18. package/src/lib/calendar/data.ts +12 -1
  19. package/src/lib/calendar/events.ts +4 -2
  20. package/src/lib/calendar/hooks.ts +76 -31
  21. package/src/lib/calendar/requests.ts +2 -3
  22. package/src/lib/calendar/schedule.ts +4 -4
  23. package/src/lib/content-service.ts +15 -12
  24. package/src/lib/editor/schedule.ts +3 -0
  25. package/src/lib/mobile/calendar.ts +49 -11
  26. package/src/lib/mobile/openapi.ts +3 -1
  27. package/src/lib/mobile/schemas.ts +8 -3
  28. package/src/lib/mobile/search.ts +5 -6
  29. package/src/lib/push/service.ts +1 -1
  30. package/src/lib/repo/migrations.ts +15 -0
  31. package/src/lib/repo/sql.ts +36 -5
  32. package/src/lib/repo/types.ts +21 -0
  33. package/src/lib/scheduler.ts +3 -0
  34. package/src/lib/schema.ts +10 -0
  35. package/src/lib/sync/catalog.ts +2 -0
  36. package/src/lib/sync/store.ts +4 -0
  37. package/src/middleware.ts +13 -2
  38. package/src/pages/admin/calendar/events/index.astro +4 -9
  39. package/src/pages/admin/calendar/resources/[id].astro +13 -1
  40. package/src/pages/api/calendar/docs.ts +2 -0
  41. package/src/pages/api/calendar/events/[id].ts +5 -5
  42. package/src/pages/api/calendar/events/index.ts +7 -4
  43. package/src/pages/api/v1/rooms/free.ts +7 -0
  44. package/src/pages/api/webhooks/docs.ts +5 -0
  45. package/src/server/index.ts +37 -0
  46. package/src/styles/admin.css +3 -0
  47. 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 { eventChanged } from './calendar/hooks';
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
- export async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, before: CalEvent, after: CalEvent, applied = false) {
223
- const settings = await repo.getSettings();
224
- const origin = siteOrigin(settings, hooks.url);
225
- await eventChanged(repo, before, after, {
226
- origin, waitUntil: waitUntilFrom(hooks.locals), applied, onRedirect: hooks.onRedirect,
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) await offerEventWaitlist(repo, after.id, { origin, locals: hooks.locals, waitUntil: waitUntilFrom(hooks.locals) });
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, before, event);
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
- const before = await repo.getEvent(id);
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, before, event, true);
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
- id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay,
62
- cancelled: o.cancelled,
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 ? { id: `${e.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, cancelled: o.cancelled } : null,
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
  });
@@ -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 ? { start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled } : null,
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 },
@@ -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 by eventChanged on every path: queues (or postpones) a change notice when a published event's schedule or place differs. */
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
 
@@ -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 = ?, updated_at = ? WHERE 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}`,
@@ -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.
@@ -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).
@@ -147,6 +147,8 @@ const boolFields = new Set([
147
147
  "allDay",
148
148
  "cancelled",
149
149
  "showOnCalendar",
150
+ "showOnEvents",
151
+ "requestable",
150
152
  "isDefault",
151
153
  "autoConfirm",
152
154
  "askPhone",
@@ -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 { eventChanged, eventDeleted } from '../../../../lib/calendar/hooks';
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
- const before = await repo.getEvent(id);
130
- if (!before) continue;
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>