@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.
- package/CHANGELOG.md +32 -0
- package/cli/main.mjs +3 -2
- package/cli/migrations.generated.mjs +11 -0
- package/docs/calendar.md +112 -0
- package/docs/mobile.md +1 -0
- package/docs/server.md +47 -2
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/actions/index.ts +14 -4
- package/src/components/calendar/QuickAdd.tsx +1 -1
- package/src/components/calendar/RoomHolds.tsx +23 -5
- package/src/lib/booking/holds.ts +98 -2
- package/src/lib/booking/service.ts +2 -1
- package/src/lib/calendar/agent.ts +2 -1
- package/src/lib/calendar/api.ts +16 -3
- package/src/lib/calendar/data.ts +12 -1
- package/src/lib/calendar/events.ts +4 -2
- package/src/lib/calendar/hooks.ts +2 -2
- package/src/lib/calendar/schedule.ts +20 -5
- package/src/lib/editor/schedule.ts +3 -0
- package/src/lib/mobile/calendar.ts +49 -11
- package/src/lib/mobile/openapi.ts +3 -1
- package/src/lib/mobile/schemas.ts +8 -3
- package/src/lib/mobile/search.ts +5 -6
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +23 -8
- package/src/lib/repo/types.ts +7 -0
- package/src/lib/schema.ts +10 -0
- package/src/lib/sync/catalog.ts +2 -0
- package/src/pages/admin/calendar/resources/[id].astro +13 -1
- package/src/pages/api/calendar/events/[id].ts +2 -2
- package/src/pages/api/calendar/events/index.ts +7 -4
- package/src/pages/api/v1/rooms/free.ts +7 -0
- package/src/server/index.ts +37 -0
- package/src/styles/admin.css +3 -0
- package/src/styles/editor.css +2 -0
package/src/lib/calendar/data.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// agenda. Native events are expanded from the DB; feed calendars are fetched
|
|
4
4
|
// live through the cached ICS loader. Visibility rules live in events.ts.
|
|
5
5
|
import type { ContentRepository } from '../repo/types';
|
|
6
|
-
import type
|
|
6
|
+
import { roomColor, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
|
|
7
7
|
import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
|
|
8
8
|
import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
|
|
9
9
|
import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
|
|
@@ -16,8 +16,14 @@ export interface ViewEvent extends CalendarEvent {
|
|
|
16
16
|
imageAlt?: string;
|
|
17
17
|
calendarName?: string;
|
|
18
18
|
event?: CalEvent;
|
|
19
|
+
// native events: the rooms this date holds; public views name only rooms
|
|
20
|
+
// set to show on public events (Resource.showOnEvents)
|
|
21
|
+
rooms?: ViewRoom[];
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
// color: '#rrggbb', or '' when the room has none
|
|
25
|
+
export interface ViewRoom { id: string; slug: string; name: string; color: string }
|
|
26
|
+
|
|
21
27
|
export interface ViewScope {
|
|
22
28
|
audience: 'public' | 'admin';
|
|
23
29
|
// limit to these calendars; omitted = every calendar the audience may see
|
|
@@ -70,6 +76,10 @@ export async function collectViewEvents(
|
|
|
70
76
|
if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(e));
|
|
71
77
|
return shown.get(e.id)!;
|
|
72
78
|
};
|
|
79
|
+
const resources = natives.some((e) => e.roomHold.resourceIds.length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
|
|
80
|
+
const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
|
|
81
|
+
.filter((r): r is Resource => Boolean(r) && (scope.audience === 'admin' || r!.showOnEvents))
|
|
82
|
+
.map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
|
|
73
83
|
for (const o of expandEvents(natives, settings, win)) {
|
|
74
84
|
const e = o.event;
|
|
75
85
|
const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
|
|
@@ -89,6 +99,7 @@ export async function collectViewEvents(
|
|
|
89
99
|
imageAlt: e.coverAlt || undefined,
|
|
90
100
|
calendarName: cal?.name,
|
|
91
101
|
event: view(e),
|
|
102
|
+
rooms: rooms(o.roomIds),
|
|
92
103
|
});
|
|
93
104
|
}
|
|
94
105
|
|
|
@@ -122,6 +122,8 @@ export interface Occurrence {
|
|
|
122
122
|
key: string;
|
|
123
123
|
// the whole event is cancelled, or just this date
|
|
124
124
|
cancelled: boolean;
|
|
125
|
+
// the rooms this date holds (Resource ids; the event's roomHold.resourceIds)
|
|
126
|
+
roomIds: string[];
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
/**
|
|
@@ -148,7 +150,7 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
|
|
|
148
150
|
const cancelledDates = new Set(e.cancelledDates ?? []);
|
|
149
151
|
for (const o of expandVEvents([v], tz, win, 0)) {
|
|
150
152
|
const key = occurrenceKey(e, o.start, settings, win.displayTz);
|
|
151
|
-
out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key) });
|
|
153
|
+
out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key), roomIds: e.roomHold?.resourceIds ?? [] });
|
|
152
154
|
}
|
|
153
155
|
}
|
|
154
156
|
return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
|
|
@@ -196,7 +198,7 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
|
|
|
196
198
|
const en = parseWall(e.end) ?? s;
|
|
197
199
|
const days = e.allDay && s && en ? Math.round((wallMinutes(en) - wallMinutes(s)) / 1440) + 1 : 1;
|
|
198
200
|
const key = occurrenceKey(e, startAt, settings, displayTz);
|
|
199
|
-
return { event: e, start: startAt, end: endAt, allDay: e.allDay, days, key, cancelled: e.cancelled || (e.cancelledDates ?? []).includes(key) };
|
|
201
|
+
return { event: e, start: startAt, end: endAt, allDay: e.allDay, days, key, cancelled: e.cancelled || (e.cancelledDates ?? []).includes(key), roomIds: e.roomHold?.resourceIds ?? [] };
|
|
200
202
|
}
|
|
201
203
|
const next = expandEvents([e], settings, { displayTz, now, windowStartMs: now, windowEndMs: now + 3 * 366 * DAY })[0];
|
|
202
204
|
if (next) return next;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
event: publicEventDto(o.event, origin, publicCalendars, settings, locale),
|
|
64
|
+
...occurrenceDto(o, resources),
|
|
65
|
+
event: publicEventDto(o.event, origin, publicCalendars, settings, locale, resources),
|
|
64
66
|
})),
|
|
65
67
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
66
68
|
};
|
|
@@ -74,7 +76,7 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
|
|
|
74
76
|
requireCalendar();
|
|
75
77
|
const q = eventDetailQuery.parse(queryParams(url));
|
|
76
78
|
const locale = mobileLocale(q.locale);
|
|
77
|
-
const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
79
|
+
const [settings, calendars, resources] = await Promise.all([repo.getSettings(), repo.listCalendars(), roomMap(repo)]);
|
|
78
80
|
const found = ref && ref.length <= 200 ? await repo.getEventBySlug(ref) ?? await repo.getEvent(ref) : null;
|
|
79
81
|
const all = new Map(calendars.map(c => [c.id, c]));
|
|
80
82
|
if (!found || !eventIsPublic(found, all)) throw new ApiError(404, 'Event not found.');
|
|
@@ -83,13 +85,20 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
|
|
|
83
85
|
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public').map(c => [c.id, c]));
|
|
84
86
|
return {
|
|
85
87
|
locale,
|
|
86
|
-
occurrence: o ?
|
|
87
|
-
event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale),
|
|
88
|
+
occurrence: o ? occurrenceDto(o, resources) : null,
|
|
89
|
+
event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale, resources),
|
|
88
90
|
};
|
|
89
91
|
}
|
|
92
|
+
/** every room by id, for naming the public ones (Resource.showOnEvents) */
|
|
93
|
+
export const roomMap = async (repo: ContentRepository) => new Map((await repo.listResources()).map((r) => [r.id, r]));
|
|
94
|
+
// one date: its time, whether it's cancelled, and the public rooms it holds
|
|
95
|
+
export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>) {
|
|
96
|
+
return { id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, cancelled: o.cancelled,
|
|
97
|
+
rooms: roomsToJson(o.roomIds, resources) };
|
|
98
|
+
}
|
|
90
99
|
// e is already localized; publicCalendars limits which calendars are named
|
|
91
|
-
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null) {
|
|
92
|
-
const event = eventToJson(e, origin, publicCalendars, settings.timezone);
|
|
100
|
+
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null, resources?: Map<string, Resource>) {
|
|
101
|
+
const event = eventToJson(e, origin, publicCalendars, settings.timezone, { resources });
|
|
93
102
|
event.url = localizedUrl(event.url, origin, locale);
|
|
94
103
|
// Only configured public event fields; no undeclared imported metadata, no private fields.
|
|
95
104
|
event.fields = publicEventFields(e.fields);
|
|
@@ -137,3 +146,32 @@ export async function availabilityFor(repo: ContentRepository, resource: Resourc
|
|
|
137
146
|
})),
|
|
138
147
|
};
|
|
139
148
|
}
|
|
149
|
+
|
|
150
|
+
// Free or busy for one room over a span, for people asking before they
|
|
151
|
+
// request a time (a request form, the app). Only rooms set as requestable
|
|
152
|
+
// answer (others are "not found"), and only the busy times come back, never
|
|
153
|
+
// what or who: event holds with their setup and cleanup, bookings, blocks and
|
|
154
|
+
// the room's busy calendars, merged. A day is the site's midnight; a
|
|
155
|
+
// date-time is that instant. 30 checks a minute per address.
|
|
156
|
+
export const roomFreeQuery = z.object({ room: z.string().min(1).max(200), start: isoDayOrInstant, end: isoDayOrInstant }).strict();
|
|
157
|
+
export async function roomFree(c: PublicContext) {
|
|
158
|
+
requireCalendar(true);
|
|
159
|
+
const q = roomFreeQuery.parse(queryParams(c.url));
|
|
160
|
+
if (!await c.repo.email.rateLimit(`rooms:free:${await sha256Hex(c.address)}`, 30, 60_000)) throw new ApiError(429, 'Too many checks. Try again in a minute.');
|
|
161
|
+
const settings = await c.repo.getSettings();
|
|
162
|
+
const tz = settings.timezone || 'UTC';
|
|
163
|
+
const at = (v: string) => (v.length === 10 ? localMidnight(v, tz) : Date.parse(v));
|
|
164
|
+
const start = at(q.start), end = at(q.end);
|
|
165
|
+
if (end <= start || end - start > 31 * 86_400_000 + 3_600_000) throw new ApiError(400, 'Use a span of up to 31 days that ends after it starts.');
|
|
166
|
+
const room = await c.repo.getResourceBySlug(q.room);
|
|
167
|
+
if (!room || !room.requestable) throw new ApiError(404, 'Room not found.');
|
|
168
|
+
const busy: { start: number; end: number }[] = [];
|
|
169
|
+
for (const b of await roomBusy(c.repo, settings, { resourceIds: [room.id], from: start, to: end })) {
|
|
170
|
+
const s = Math.max(b.start, start), e = Math.min(b.end, end);
|
|
171
|
+
const last = busy[busy.length - 1];
|
|
172
|
+
if (last && s <= last.end) last.end = Math.max(last.end, e);
|
|
173
|
+
else busy.push({ start: s, end: e });
|
|
174
|
+
}
|
|
175
|
+
const iso = (n: number) => new Date(n).toISOString();
|
|
176
|
+
return { room: { slug: room.slug, name: room.name }, start: iso(start), end: iso(end), free: !busy.length, busy: busy.map((b) => ({ start: iso(b.start), end: iso(b.end) })) };
|
|
177
|
+
}
|
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
3
3
|
import type { ContentRepository } from '../repo/types';
|
|
4
4
|
import { contentQuery, contentDetailQuery } from './content';
|
|
5
|
-
import { eventsQuery, eventDetailQuery, availabilityQuery } from './calendar';
|
|
5
|
+
import { eventsQuery, eventDetailQuery, availabilityQuery, roomFreeQuery } from './calendar';
|
|
6
6
|
import { bookingInput } from './bookings';
|
|
7
7
|
import { rsvpQuery } from './rsvp';
|
|
8
8
|
import { pagesQuery } from './pages';
|
|
@@ -109,6 +109,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
109
109
|
description: `${manageNote} Past its holdUntil the spot was released (410) and goes to the next person in line; a booking with no held spot answers 409.` }), ...cors };
|
|
110
110
|
paths['/api/v1/bookings/manage/availability'] = { get: priv('Open times for moving the booking (its own time counts as free)', ref('Availability'), { header: bookingToken, query: manageAvailabilityQuery, description: manageNote }), ...cors };
|
|
111
111
|
paths['/api/v1/bookings/manage/reschedule'] = { post: priv('Move the booking to a new start (room bookings, not RSVPs)', ref('ManagedBooking'), { header: bookingToken, body: rescheduleInput, write: true, description: manageNote }), ...cors };
|
|
112
|
+
paths['/api/v1/rooms/free'] = { get: priv('Free or busy for one room over a span, without names; requires bookings', ref('RoomFree'), { query: roomFreeQuery,
|
|
113
|
+
description: 'Only rooms marked “Anyone can check if it’s free” answer; any other slug is 404. start and end are a site-local day (its midnight) or an ISO date-time with a zone; at most 31 days. busy lists the taken times inside the span, merged and clipped: events holding the room (with their setup and cleanup), bookings, staff blocks and the room’s busy calendars, never what or who. Never cached. 30 checks per minute per client address (429).' }), ...cors };
|
|
112
114
|
paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
|
|
113
115
|
responses: { ...errors, 200: { description: 'An iCalendar file.', content: { 'text/calendar': { schema: { type: 'string' } } } } } }, ...cors };
|
|
114
116
|
paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
|
|
@@ -27,23 +27,26 @@ export const Page = ContentSummary.extend({
|
|
|
27
27
|
kind: z.literal('page'), body: Html, cover: str, seo: z.object({ title: str, description: str }),
|
|
28
28
|
mode: z.enum(['content', 'sections']), sections: z.array(Section),
|
|
29
29
|
});
|
|
30
|
+
// a room an event (or one date) holds, named only when set to show on public events; color null = none
|
|
31
|
+
export const EventRoom = z.object({ slug: str, name: str, color: str.nullable() });
|
|
30
32
|
export const Event = CalEvent.pick({
|
|
31
33
|
id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
|
|
32
34
|
allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
|
|
33
35
|
}).extend({ rsvp: CalEvent.shape.rsvp.removeDefault().omit({ notifyStaff: true }).default({}).optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
|
|
34
36
|
calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
|
|
37
|
+
rooms: z.array(EventRoom),
|
|
35
38
|
// push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
|
|
36
39
|
notify: z.object({ changes: strings }),
|
|
37
40
|
});
|
|
38
41
|
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
39
|
-
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, event: Event })),
|
|
42
|
+
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom), event: Event })),
|
|
40
43
|
});
|
|
41
44
|
const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
|
|
42
45
|
const ContentHit = ContentSummary.extend({ snippet: Snippet });
|
|
43
|
-
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event });
|
|
46
|
+
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom) }).nullable(), event: Event });
|
|
44
47
|
export const SearchResults = z.object({ locale, query: str,
|
|
45
48
|
posts: z.array(ContentHit), pages: z.array(ContentHit),
|
|
46
|
-
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event })),
|
|
49
|
+
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom) }).nullable(), event: Event })),
|
|
47
50
|
more: z.object({ posts: bool, pages: bool, events: bool }),
|
|
48
51
|
});
|
|
49
52
|
const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
|
|
@@ -114,6 +117,8 @@ export const Resources = z.object({ pagination: Pagination, resources: z.array(z
|
|
|
114
117
|
askPhone: bool, autoConfirm: bool, url: str, questions: z.array(BookingQuestion.omit({ help: true })),
|
|
115
118
|
})),
|
|
116
119
|
})) });
|
|
120
|
+
// GET /rooms/free: free = nothing takes the room in [start, end); busy = the taken times, no names
|
|
121
|
+
export const RoomFree = z.object({ room: z.object({ slug: str, name: str }), start: date, end: date, free: bool, busy: z.array(z.object({ start: date, end: date })) });
|
|
117
122
|
export const Availability = z.object({ timezone: str, mode: z.enum(['slots', 'day', 'range']), lengths: z.array(z.number()), autoConfirm: bool,
|
|
118
123
|
days: z.array(z.object({ date: str, slots: z.array(z.object({ start: date, end: date, minutes: z.number() })) })),
|
|
119
124
|
});
|
package/src/lib/mobile/search.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { eventIsListed, featuredOccurrence } from '../calendar/events';
|
|
|
9
9
|
import { localizeDoc } from '../translations';
|
|
10
10
|
import { ApiError } from '../api';
|
|
11
11
|
import { contentDto } from './content';
|
|
12
|
-
import { publicEventDto } from './calendar';
|
|
12
|
+
import { occurrenceDto, publicEventDto, roomMap } from './calendar';
|
|
13
13
|
import { queryParams } from './http';
|
|
14
14
|
import { localeQuery, mobileLocale } from './locale';
|
|
15
15
|
|
|
@@ -56,9 +56,9 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
56
56
|
const q = searchQuery.parse(queryParams(url));
|
|
57
57
|
if (q.collection && !Object.hasOwn(CONTENT.collections, q.collection)) throw new ApiError(400, 'Unknown collection.');
|
|
58
58
|
const locale = mobileLocale(q.locale);
|
|
59
|
-
const [found, settings, calendars] = await Promise.all([
|
|
59
|
+
const [found, settings, calendars, resources] = await Promise.all([
|
|
60
60
|
repo.searchPublished(q.q, { locale: normalizePageLocale(locale ?? ''), limit: q.limit + 1, collections: q.collection ? [q.collection] : Object.keys(CONTENT.collections) }),
|
|
61
|
-
repo.getSettings(), repo.listCalendars(),
|
|
61
|
+
repo.getSettings(), repo.listCalendars(), roomMap(repo),
|
|
62
62
|
]);
|
|
63
63
|
const origin = siteOrigin(settings, url);
|
|
64
64
|
const want = new Set(q.types);
|
|
@@ -75,7 +75,6 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
75
75
|
events = deeper.events.filter(({ doc }) => eventIsListed(doc, all));
|
|
76
76
|
}
|
|
77
77
|
const now = Date.now();
|
|
78
|
-
const iso = (n: number) => new Date(n).toISOString();
|
|
79
78
|
return {
|
|
80
79
|
locale, query: q.q,
|
|
81
80
|
posts: posts.slice(0, q.limit).map(({ doc, snippet }) => ({ ...contentDto(doc, 'post', origin, false, locale), snippet: plainSnippet(snippet) })),
|
|
@@ -86,8 +85,8 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
86
85
|
return {
|
|
87
86
|
snippet: plainSnippet(snippet),
|
|
88
87
|
// the next occurrence, or the latest one when the event is over
|
|
89
|
-
occurrence: o ? {
|
|
90
|
-
event: publicEventDto(e, origin, listed, settings, locale),
|
|
88
|
+
occurrence: o ? (({ id: _, ...rest }) => rest)(occurrenceDto(o, resources)) : null,
|
|
89
|
+
event: publicEventDto(e, origin, listed, settings, locale, resources),
|
|
91
90
|
};
|
|
92
91
|
}),
|
|
93
92
|
more: { posts: posts.length > q.limit, pages: pages.length > q.limit, events: events.length > q.limit },
|
|
@@ -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
|
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { LiveConfig } from '../live/types';
|
|
|
13
13
|
import { ApiError } from '../api';
|
|
14
14
|
import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
|
|
15
15
|
import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
|
|
16
|
-
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
16
|
+
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
17
17
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
18
18
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
19
19
|
import { cleanRrule } from '../calendar/rrule';
|
|
@@ -172,6 +172,7 @@ function rowToResource(r: Row): Resource {
|
|
|
172
172
|
amenities: json(r.amenities, []), timezone: r.timezone ?? '', hours: json(r.hours, {}), overrides: json(r.overrides, []),
|
|
173
173
|
busyFeeds: json(r.busy_feeds, []), notifyEmails: json(r.notify_emails, []), visibility: r.visibility ?? 'public',
|
|
174
174
|
showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', placeId: r.place_id ?? '', order: Number(r.ord ?? 0),
|
|
175
|
+
color: r.color ?? '', showOnEvents: Boolean(r.show_on_events), requestable: Boolean(r.requestable),
|
|
175
176
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
176
177
|
});
|
|
177
178
|
}
|
|
@@ -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 = ?,
|
|
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[]) {
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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).
|
package/src/lib/sync/catalog.ts
CHANGED
|
@@ -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
|
}),
|