@acms-subir/core 0.3.44 → 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
 
@@ -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
  }
@@ -1083,10 +1084,10 @@ export class SqlRepository implements ContentRepository {
1083
1084
  private resourceInsert(r: Resource): SqlStatement {
1084
1085
  return {
1085
1086
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1087
+ notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id, color, show_on_events, requestable) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1087
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),
1088
1089
  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],
1090
+ r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0],
1090
1091
  };
1091
1092
  }
1092
1093
  private linkInsert(l: BookingLink): SqlStatement {
@@ -1137,10 +1138,11 @@ export class SqlRepository implements ContentRepository {
1137
1138
  const r = Resource.parse({ ...cur, ...data, id, updatedAt: Date.now() });
1138
1139
  await this.driver.run(
1139
1140
  `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 = ?`,
1141
+ overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, place_id = ?,
1142
+ color = ?, show_on_events = ?, requestable = ?, updated_at = ? WHERE id = ?`,
1141
1143
  [r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
1142
1144
  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]
1145
+ r.feedToken, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0, r.updatedAt, id]
1144
1146
  );
1145
1147
  }
1146
1148
  async reorderResources(ids: string[]) {
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
  }),
@@ -0,0 +1,7 @@
1
+ import { roomFree } from '../../../../lib/mobile/calendar';
2
+ import { privateRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ // never cached: a room taken a minute ago must not read as free
6
+ export const GET = privateRoute(roomFree);
7
+ export const ALL = () => methodNotAllowed('GET, OPTIONS');
@@ -36,6 +36,7 @@ export interface ServerUser {
36
36
 
37
37
  export type { CalEvent, Occurrence };
38
38
  export type { EventRequestInput } from '../lib/calendar/requests';
39
+ export type { EventClash, BusySlot, Hold } from '../lib/booking/holds';
39
40
 
40
41
  export interface ListEventsOptions {
41
42
  /** only published events (default: every event except archived ones) */
@@ -171,6 +172,42 @@ export async function restoreOccurrence(repo: Repo, eventId: string, occurrenceK
171
172
  return restore(internal(repo), eventId, occurrenceKey, { url: context.url, locals: context.locals });
172
173
  }
173
174
 
175
+ // ---- rooms -----------------------------------------------------------------
176
+ // Events hold rooms (CalEvent.roomHold) while they happen. These find where
177
+ // two things want the same room. They don't check permissions, and their
178
+ // results carry names: show them to staff only.
179
+
180
+ /**
181
+ * Other events holding the same rooms as this one at overlapping times, setup
182
+ * and cleanup included, soonest first. Pass an event id, or a CalEvent with
183
+ * unsaved values (it's checked as if published). Looks `horizonDays` ahead
184
+ * (default 366) from `now`. Clashes are warnings: both events keep the room.
185
+ * Throws an Error with `status` 404 for an unknown event id.
186
+ */
187
+ export async function eventRoomClashes(repo: Repo, event: string | CalEvent, opts: { horizonDays?: number; now?: Date | number } = {}): Promise<import('../lib/booking/holds').EventClash[]> {
188
+ const r = internal(repo);
189
+ const e = typeof event === 'string' ? await r.getEvent(event) : event;
190
+ if (!e) throw Object.assign(new Error('Event not found.'), { status: 404 });
191
+ const { eventClashes } = await import('../lib/booking/holds');
192
+ return (await eventClashes(r, await r.getSettings(), e, { horizonDays: opts.horizonDays, now: opts.now === undefined ? undefined : +opts.now })).clashes;
193
+ }
194
+
195
+ /**
196
+ * Everything that takes these rooms (Resource ids) in [from, to), soonest
197
+ * first: events holding them (setup and cleanup included), pending and
198
+ * confirmed bookings, staff blocks and the rooms' busy calendars. `ignore`
199
+ * leaves out one event, or one date of it by occurrence key, such as the date
200
+ * you're about to move. An empty list means they're all free.
201
+ */
202
+ export async function roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<import('../lib/booking/holds').BusySlot[]> {
203
+ const from = +q.from, to = +q.to;
204
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw new RangeError('roomBusy needs a "to" after "from".');
205
+ if (to - from > MAX_WINDOW) throw new RangeError('roomBusy windows are at most 400 days.');
206
+ const r = internal(repo);
207
+ const { roomBusy: busy } = await import('../lib/booking/holds');
208
+ return busy(r, await r.getSettings(), { resourceIds: q.roomIds, from, to, ignore: q.ignore });
209
+ }
210
+
174
211
  // ---- members' event requests ------------------------------------------
175
212
  // The rules behind the built-in "My events" pages (acms({ members })), for a
176
213
  // site that builds its own. Each takes the person acting (currentUser()) and
@@ -1392,6 +1392,9 @@ em.manual { color: var(--amber); }
1392
1392
  .res-grid > div > .card + .card, .res-side > .card + .card { margin-top: 16px; }
1393
1393
  .res-side { display: flex; flex-direction: column; gap: 0; }
1394
1394
  .res-side > .btn { margin-top: 14px; align-self: flex-start; }
1395
+ .res-color { display: flex; align-items: center; gap: 12px; margin-top: 4px; }
1396
+ .res-color input[type='color'] { width: 44px; height: 30px; padding: 2px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--panel); cursor: pointer; }
1397
+ .res-color .check-row { margin: 0; }
1395
1398
  .card-pad { padding: 16px 18px; }
1396
1399
  .card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 12px; }
1397
1400
  .card-head b { font-size: 14.5px; }
@@ -1039,6 +1039,8 @@
1039
1039
  .rh-conflict > b { display: block; margin-bottom: 4px; }
1040
1040
  .rh-bk { display: flex; flex-direction: column; padding: 5px 0; border-top: 1px solid color-mix(in srgb, var(--amber) 25%, transparent); }
1041
1041
  .rh-bk small, .rh-more { color: var(--text-2); font-size: 11.5px; }
1042
+ .rh-clash .rh-bk a { color: inherit; font-weight: 600; }
1043
+ .rh-clash > .rh-more:last-child { display: block; margin-top: 6px; }
1042
1044
  .rh-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
1043
1045
  .rh-acts.end { justify-content: flex-end; }
1044
1046
  .rh-cancel textarea, .rh-msg { width: 100%; margin-top: 8px; padding: 7px 9px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--bg); color: var(--text); font: inherit; font-size: 12.5px; resize: vertical; }