@acms-subir/core 0.3.43 → 0.3.45

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.
@@ -3,7 +3,7 @@
3
3
  // agenda. Native events are expanded from the DB; feed calendars are fetched
4
4
  // live through the cached ICS loader. Visibility rules live in events.ts.
5
5
  import type { ContentRepository } from '../repo/types';
6
- import type { CalEvent, Calendar, Settings } from '../schema';
6
+ import { roomColor, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
7
7
  import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
8
8
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
9
9
  import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
@@ -16,8 +16,14 @@ export interface ViewEvent extends CalendarEvent {
16
16
  imageAlt?: string;
17
17
  calendarName?: string;
18
18
  event?: CalEvent;
19
+ // native events: the rooms this date holds; public views name only rooms
20
+ // set to show on public events (Resource.showOnEvents)
21
+ rooms?: ViewRoom[];
19
22
  }
20
23
 
24
+ // color: '#rrggbb', or '' when the room has none
25
+ export interface ViewRoom { id: string; slug: string; name: string; color: string }
26
+
21
27
  export interface ViewScope {
22
28
  audience: 'public' | 'admin';
23
29
  // limit to these calendars; omitted = every calendar the audience may see
@@ -70,6 +76,10 @@ export async function collectViewEvents(
70
76
  if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(e));
71
77
  return shown.get(e.id)!;
72
78
  };
79
+ const resources = natives.some((e) => e.roomHold.resourceIds.length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
80
+ const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
81
+ .filter((r): r is Resource => Boolean(r) && (scope.audience === 'admin' || r!.showOnEvents))
82
+ .map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
73
83
  for (const o of expandEvents(natives, settings, win)) {
74
84
  const e = o.event;
75
85
  const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
@@ -89,6 +99,7 @@ export async function collectViewEvents(
89
99
  imageAlt: e.coverAlt || undefined,
90
100
  calendarName: cal?.name,
91
101
  event: view(e),
102
+ rooms: rooms(o.roomIds),
92
103
  });
93
104
  }
94
105
 
@@ -122,6 +122,8 @@ export interface Occurrence {
122
122
  key: string;
123
123
  // the whole event is cancelled, or just this date
124
124
  cancelled: boolean;
125
+ // the rooms this date holds (Resource ids; the event's roomHold.resourceIds)
126
+ roomIds: string[];
125
127
  }
126
128
 
127
129
  /**
@@ -148,7 +150,7 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
148
150
  const cancelledDates = new Set(e.cancelledDates ?? []);
149
151
  for (const o of expandVEvents([v], tz, win, 0)) {
150
152
  const key = occurrenceKey(e, o.start, settings, win.displayTz);
151
- out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key) });
153
+ out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key), roomIds: e.roomHold?.resourceIds ?? [] });
152
154
  }
153
155
  }
154
156
  return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
@@ -196,7 +198,7 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
196
198
  const en = parseWall(e.end) ?? s;
197
199
  const days = e.allDay && s && en ? Math.round((wallMinutes(en) - wallMinutes(s)) / 1440) + 1 : 1;
198
200
  const key = occurrenceKey(e, startAt, settings, displayTz);
199
- return { event: e, start: startAt, end: endAt, allDay: e.allDay, days, key, cancelled: e.cancelled || (e.cancelledDates ?? []).includes(key) };
201
+ return { event: e, start: startAt, end: endAt, allDay: e.allDay, days, key, cancelled: e.cancelled || (e.cancelledDates ?? []).includes(key), roomIds: e.roomHold?.resourceIds ?? [] };
200
202
  }
201
203
  const next = expandEvents([e], settings, { displayTz, now, windowStartMs: now, windowEndMs: now + 3 * 366 * DAY })[0];
202
204
  if (next) return next;
@@ -27,8 +27,8 @@ export async function eventChanged(
27
27
  else if (before.status === 'published' && after.status !== 'published') type = 'event.unpublished';
28
28
  else if (after.status === 'published' && (opts.applied || before.updatedAt !== after.updatedAt)) type = 'event.updated';
29
29
  if (!type) return;
30
- const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
31
- const data = eventToJson(after, opts.origin, new Map(calendars.map((c) => [c.id, c])), settings.timezone);
30
+ const [settings, calendars, rooms] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listResources()]);
31
+ const data = eventToJson(after, opts.origin, new Map(calendars.map((c) => [c.id, c])), settings.timezone, { resources: new Map(rooms.map((r) => [r.id, r])) });
32
32
  await emitWebhook(repo, type, { event: data }, { waitUntil: opts.waitUntil }).catch(() => {});
33
33
  }
34
34
 
@@ -121,7 +121,9 @@ export async function moveSeries(repo: ContentRepository, id: string, days: numb
121
121
 
122
122
  // Move one occurrence of a repeating event: skip that date in the series and
123
123
  // add a one-off copy on the new date. No schema change, and the copy is a
124
- // normal event the editor can open.
124
+ // normal event the editor can open. The copy keeps the series' hosts, room
125
+ // hold and translations, and that date's RSVPs move to it (people get the
126
+ // "new time" email).
125
127
  export async function moveOccurrence(repo: ContentRepository, id: string, occurrence: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
126
128
  const e = await repo.getEvent(id);
127
129
  if (!e) throw new ApiError(404, 'Event not found.');
@@ -139,16 +141,22 @@ export async function moveOccurrence(repo: ContentRepository, id: string, occurr
139
141
  const endDay = `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
140
142
  const endStr = e.allDay ? endDay : `${endDay}T${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}`;
141
143
 
144
+ const settings = await repo.getSettings();
145
+ const was = findOccurrence(e, occurrence, settings);
142
146
  await saveNow(repo, e, { exdates: [...e.exdates, occurrence] }, hooks);
143
147
  const copy = await repo.createEvent({
144
148
  title: e.title, slug: await uniqueSlug(repo, e.title), start: formatWall(newStart), end: endStr, allDay: e.allDay,
145
149
  timezone: e.timezone, status: 'draft', excerpt: e.excerpt, bodyJson: e.bodyJson, bodyHtml: e.bodyHtml, cover: e.cover, coverAlt: e.coverAlt,
146
- location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags,
150
+ seoTitle: e.seoTitle, seoDesc: e.seoDesc, location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags,
147
151
  // a cancelled date stays cancelled on its new day
148
152
  cancelled: e.cancelled || e.cancelledDates.includes(occurrence), rsvp: e.rsvp,
149
- template: e.template, featured: false,
153
+ template: e.template, featured: false, roomHold: e.roomHold, hostIds: e.hostIds, translations: e.translations,
150
154
  });
151
155
  const event = live(e.status) ? await saveEvent(repo, copy.id, { status: 'published' }, hooks) : copy;
156
+ const now = findOccurrence(event, event.start, settings);
157
+ if (was && now && await repo.moveOccurrenceRsvps(e.id, was.start, { eventId: event.id, start: now.start, end: now.end })) {
158
+ await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
159
+ }
152
160
  return { event, undo: { kind: 'split', id: copy.id, seriesId: e.id, occurrence } };
153
161
  }
154
162
 
@@ -157,13 +165,20 @@ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: S
157
165
  await moveSeries(repo, undo.id, undo.days, hooks);
158
166
  return;
159
167
  }
160
- const series = await repo.getEvent(undo.seriesId);
168
+ let series = await repo.getEvent(undo.seriesId);
161
169
  const copy = await repo.getEvent(undo.id);
170
+ if (series) series = await saveNow(repo, series, { exdates: series.exdates.filter((x) => x !== undo.occurrence) }, hooks);
162
171
  if (copy) {
172
+ // the RSVPs go back to the series' date before the copy (and its RSVPs) goes
173
+ const settings = await repo.getSettings();
174
+ const back = series && findOccurrence(series, undo.occurrence, settings);
175
+ const from = findOccurrence(copy, copy.start, settings);
176
+ if (back && from && await repo.moveOccurrenceRsvps(copy.id, from.start, { eventId: series!.id, start: back.start, end: back.end })) {
177
+ await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
178
+ }
163
179
  await repo.deleteEvent(copy.id);
164
180
  await eventDeleted(repo, copy, { waitUntil: waitUntilFrom(hooks.locals) });
165
181
  }
166
- if (series) await saveNow(repo, series, { exdates: series.exdates.filter((x) => x !== undo.occurrence) }, hooks);
167
182
  }
168
183
 
169
184
  // ---- cancel one date ----------------------------------------------------------
@@ -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 },
@@ -710,6 +710,16 @@ export const MIGRATIONS: Migration[] = [
710
710
  { version: 63, statements: [
711
711
  `ALTER TABLE forms ADD COLUMN alerts TEXT NOT NULL DEFAULT '{}'`,
712
712
  ] },
713
+ // rooms: a colour and whether public event data names them
714
+ // (Resource.color / .showOnEvents, both visible), and whether anyone may ask
715
+ // if they're free (Resource.requestable, never shown, so no cache bump)
716
+ { version: 64, statements: [
717
+ `ALTER TABLE resources ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
718
+ `ALTER TABLE resources ADD COLUMN show_on_events INTEGER NOT NULL DEFAULT 0`,
719
+ `ALTER TABLE resources ADD COLUMN requestable INTEGER NOT NULL DEFAULT 0`,
720
+ `DROP TRIGGER content_version_resources_update`,
721
+ contentVersionTriggers('resources', [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'])[2],
722
+ ] },
713
723
  ];
714
724
 
715
725
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -717,6 +727,7 @@ export const CURRENT_CONTENT_VERSION_COLUMNS = {
717
727
  ...CONTENT_VERSION_COLUMNS,
718
728
  settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
719
729
  events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
730
+ resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'],
720
731
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
721
732
  };
722
733
 
@@ -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
  }
@@ -847,8 +848,8 @@ export class SqlRepository implements ContentRepository {
847
848
  await this.driver.run(
848
849
  `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
849
850
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
850
- published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request)
851
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
851
+ published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations)
852
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
852
853
  [
853
854
  id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
854
855
  data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
@@ -857,7 +858,7 @@ export class SqlRepository implements ContentRepository {
857
858
  JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
858
859
  data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
859
860
  JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
860
- JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null,
861
+ JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null, JSON.stringify(data.translations ?? {}),
861
862
  ]
862
863
  );
863
864
  await this.syncEventIndex(id);
@@ -965,6 +966,19 @@ export class SqlRepository implements ContentRepository {
965
966
  ]);
966
967
  return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
967
968
  }
969
+ async moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }) {
970
+ await this.ready;
971
+ const target = to.eventId || id;
972
+ // one mutation id marks every moved row; only the seated ones are emailed
973
+ const mutation = crypto.randomUUID();
974
+ const notifications = bookingJobs(target,'rescheduled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')")}));
975
+ await this.driver.batch([
976
+ {sql:`UPDATE bookings SET event_id=?,start_at=?,end_at=?,hold_start=?,hold_end=?,notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=?
977
+ WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ${RSVP_ACTIVE}`,args:[target,to.start,to.end,to.start,to.end,mutation,Date.now(),id,start]},
978
+ ...notifications,
979
+ ]);
980
+ return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [target, mutation]))?.n ?? 0);
981
+ }
968
982
  async discardEventDraft(id: string) {
969
983
  await this.ready;
970
984
  await this.driver.run('UPDATE events SET draft = NULL WHERE id = ?', [id]);
@@ -1070,10 +1084,10 @@ export class SqlRepository implements ContentRepository {
1070
1084
  private resourceInsert(r: Resource): SqlStatement {
1071
1085
  return {
1072
1086
  sql: `INSERT INTO resources (id, slug, name, kind, description, photo, capacity, amenities, timezone, hours, overrides, busy_feeds,
1073
- notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1087
+ notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id, color, show_on_events, requestable) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1074
1088
  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),
1075
1089
  JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
1076
- r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId],
1090
+ r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0],
1077
1091
  };
1078
1092
  }
1079
1093
  private linkInsert(l: BookingLink): SqlStatement {
@@ -1124,10 +1138,11 @@ export class SqlRepository implements ContentRepository {
1124
1138
  const r = Resource.parse({ ...cur, ...data, id, updatedAt: Date.now() });
1125
1139
  await this.driver.run(
1126
1140
  `UPDATE resources SET slug = ?, name = ?, kind = ?, description = ?, photo = ?, capacity = ?, amenities = ?, timezone = ?, hours = ?,
1127
- overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, place_id = ?, updated_at = ? WHERE id = ?`,
1141
+ overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, place_id = ?,
1142
+ color = ?, show_on_events = ?, requestable = ?, updated_at = ? WHERE id = ?`,
1128
1143
  [r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
1129
1144
  JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
1130
- r.feedToken, r.placeId, r.updatedAt, id]
1145
+ r.feedToken, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0, r.updatedAt, id]
1131
1146
  );
1132
1147
  }
1133
1148
  async reorderResources(ids: string[]) {
@@ -155,6 +155,13 @@ export interface ContentRepository {
155
155
  setEventCancelledDates(id: string, dates: string[]): Promise<void>;
156
156
  /** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
157
157
  cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
158
+ /**
159
+ * Moves the RSVPs for one date of an event (those starting at `start`) to a
160
+ * new time, and to another event when `to.eventId` is set. Pending and
161
+ * confirmed ones get the "new time" email; places in line move quietly.
162
+ * Returns how many moved.
163
+ */
164
+ moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }): Promise<number>;
158
165
  deleteEvent(id: string): Promise<void>;
159
166
  // saved-place sync (lib/maps/places.ts): rewrite just the location of the
160
167
  // live row and/or the staged draft, leaving every other field — and the
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",
@@ -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>
@@ -27,12 +27,12 @@ export const GET: APIRoute = async ({ request, locals, url, params }) => {
27
27
  try {
28
28
  if (!CALENDAR.enabled) throw new ApiError(404, 'The calendar module is disabled.');
29
29
  const repo = await getRepo(locals);
30
- const [e, settings, calendars, who] = await Promise.all([find(repo, params.id!), repo.getSettings(), repo.listCalendars(), authenticate(request, repo)]);
30
+ const [e, settings, calendars, who, rooms] = await Promise.all([find(repo, params.id!), repo.getSettings(), repo.listCalendars(), authenticate(request, repo), repo.listResources()]);
31
31
  const map = new Map(calendars.map((c) => [c.id, c]));
32
32
  const full = who ? hasScope(who, 'events:read') : false;
33
33
  if (!e || (!full && !eventIsPublic(e, map))) throw new ApiError(404, 'Event not found.');
34
34
  const view = full ? mergeEventDraft(e) : e;
35
- return withCors(json({ event: { ...eventToJson(view, siteOrigin(settings, url), map, settings.timezone, { includePrivate: full }), ...(full ? { hasPendingDraft: Boolean(e.draft), bodyHtml: view.bodyHtml } : {}) } }));
35
+ return withCors(json({ event: { ...eventToJson(view, siteOrigin(settings, url), map, settings.timezone, { includePrivate: full, resources: new Map(rooms.map((r) => [r.id, r])) }), ...(full ? { hasPendingDraft: Boolean(e.draft), bodyHtml: view.bodyHtml } : {}) } }));
36
36
  } catch (err) { return fail(err); }
37
37
  };
38
38
 
@@ -5,7 +5,7 @@ import { authenticate, hasScope } from '../../../../lib/api-keys';
5
5
  import { siteOrigin } from '../../../../lib/seo';
6
6
  import { CALENDAR } from '../../../../lib/calendar/config';
7
7
  import { eventIsListed, eventIsPublic, expandEvents, parseWall } from '../../../../lib/calendar/events';
8
- import { eventToJson } from '../../../../lib/calendar/api';
8
+ import { eventToJson, roomsToJson } from '../../../../lib/calendar/api';
9
9
  import { zonedToUtc } from '../../../../lib/calendar/ics';
10
10
  import { isoWithOffset } from '../../../../lib/calendar/seo';
11
11
  import { eventTimeZone } from '../../../../lib/calendar/events';
@@ -43,9 +43,10 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
43
43
  const principal = await authenticate(request, repo);
44
44
  if (request.headers.get('authorization') && !principal) throw new ApiError(401, 'Invalid API key.');
45
45
  const full = principal ? hasScope(principal, 'events:read') : false;
46
- const [settings, calendars, published] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents()]);
46
+ const [settings, calendars, published, rooms] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), repo.listResources()]);
47
47
  const tz = settings.timezone || 'UTC';
48
48
  const map = new Map(calendars.map((c) => [c.id, c]));
49
+ const resources = new Map(rooms.map((r) => [r.id, r]));
49
50
  const origin = siteOrigin(settings, url);
50
51
 
51
52
  const slug = url.searchParams.get('calendar');
@@ -64,7 +65,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
64
65
  }
65
66
 
66
67
  if (url.searchParams.get('expand') === '0') {
67
- return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full })) }));
68
+ return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full, resources })) }));
68
69
  }
69
70
  const day = (param: string, fallback: number) => {
70
71
  const w = parseWall(url.searchParams.get(param) ?? '');
@@ -78,7 +79,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
78
79
  const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to }).slice(0, limit);
79
80
  const cache = new Map<string, ReturnType<typeof eventToJson>>();
80
81
  const toJson = (id: string) => {
81
- if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone, { includePrivate: full }));
82
+ if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone, { includePrivate: full, resources }));
82
83
  return cache.get(id)!;
83
84
  };
84
85
  return withCors(json({
@@ -93,6 +94,8 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
93
94
  end: o.allDay ? isoWithOffset(o.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.end, etz),
94
95
  allDay: o.allDay,
95
96
  cancelled: o.cancelled,
97
+ // that date's rooms
98
+ rooms: roomsToJson(o.roomIds, resources, full),
96
99
  event: toJson(o.event.id),
97
100
  };
98
101
  }),