@acms-subir/core 0.3.61 → 0.3.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/cli/migrations.generated.mjs +8 -0
  3. package/docs/calendar.md +57 -1
  4. package/docs/mobile.md +7 -1
  5. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +32 -17
  6. package/mobile-template/recipes/booking/files/app/book/details.tsx +34 -25
  7. package/mobile-template/recipes/booking/files/app/book/index.tsx +11 -5
  8. package/mobile-template/recipes/booking/files/app/book/manage.tsx +82 -0
  9. package/mobile-template/recipes/booking/recipe.json +6 -3
  10. package/package.json +1 -1
  11. package/src/components/MobileApp.tsx +11 -6
  12. package/src/components/booking/BookingFlow.astro +16 -8
  13. package/src/lib/booking/public.ts +40 -0
  14. package/src/lib/booking/service.ts +7 -3
  15. package/src/lib/booking/view.ts +6 -0
  16. package/src/lib/calendar/data.ts +25 -31
  17. package/src/lib/calendar/events.ts +12 -2
  18. package/src/lib/calendar/google/map.ts +1 -1
  19. package/src/lib/display/board.ts +5 -3
  20. package/src/lib/editor/schedule.ts +2 -0
  21. package/src/lib/mobile/calendar.ts +41 -14
  22. package/src/lib/mobile/guest.ts +4 -2
  23. package/src/lib/mobile/manage.ts +8 -4
  24. package/src/lib/mobile/openapi.ts +4 -4
  25. package/src/lib/mobile/schemas.ts +6 -1
  26. package/src/lib/mobile/search.ts +7 -6
  27. package/src/lib/push/api.ts +4 -2
  28. package/src/lib/push/service.ts +26 -15
  29. package/src/lib/repo/migrations.ts +8 -0
  30. package/src/lib/repo/sql.ts +10 -5
  31. package/src/lib/schema.ts +21 -6
  32. package/src/lib/sections.ts +4 -2
  33. package/src/pages/admin/calendar/resources/link.astro +14 -1
  34. package/src/pages/api/search.ts +3 -3
  35. package/src/pages/book/[resource]/[link].astro +10 -5
  36. package/src/pages/book/[resource]/index.astro +14 -8
  37. package/src/pages/book/index.astro +8 -3
  38. package/src/pages/book/manage/[token].astro +5 -3
  39. package/src/pages/embed/book/[resource]/[link].astro +11 -3
  40. package/src/pages/search.astro +2 -2
@@ -4,7 +4,9 @@
4
4
  // is a plain link or form (?date=…&len=…&start=…), so it works without
5
5
  // JavaScript and each state is shareable. Used by the page and the embed.
6
6
  import type { Booking, BookingLink, Resource, Settings } from '../../lib/schema';
7
+ import { linkWords, roomName } from '../../lib/schema';
7
8
  import { bookingLinkUrl, resourceUrl } from '../../lib/calendar/config';
9
+ import { localePath } from '../../lib/i18n';
8
10
  import { describeLength, describeLink, linkLengths, openDays, slotsForDay, ymd, type AvailabilityContext } from '../../lib/booking/availability';
9
11
  import { addMonths, fmt, monthCells, slotLabel } from '../../lib/booking/view';
10
12
  import { confirmsInstantly, linkApproval } from '../../lib/booking/approval';
@@ -19,8 +21,13 @@ interface Props {
19
21
  values?: Record<string, string>;
20
22
  embed?: boolean;
21
23
  reschedule?: Booking | null;
24
+ /** the page's language: the link and room are named in it, and links stay in it ('' = the main one) */
25
+ locale?: string;
22
26
  }
23
- const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null } = Astro.props;
27
+ const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null, locale = '' } = Astro.props;
28
+ // the booking link and room in the page's language (the rest of the flow is in English)
29
+ const words = linkWords(link, locale);
30
+ const room = roomName(resource, locale);
24
31
  const tz = ctx.resource.timezone || settings.timezone || 'UTC';
25
32
  // "rules" links usually confirm at once, but may not — promise only what's certain
26
33
  const instant = confirmsInstantly(linkApproval(link, settings));
@@ -42,10 +49,11 @@ const startParam = Number(url.searchParams.get('start'));
42
49
  const chosen = slots.find((s) => s.start === startParam && (link.mode !== 'day' || s.minutes === Number(url.searchParams.get('len') || s.minutes)));
43
50
  const minMonth = today.slice(0, 7);
44
51
  const maxMonth = ymd(ctx.now + link.maxAdvance * 86_400_000, tz).slice(0, 7);
45
- const base = embed ? `/embed/book/${resource.slug}/${link.slug}` : bookingLinkUrl(resource.slug, link.slug);
52
+ const base = embed ? `/embed/book/${resource.slug}/${link.slug}` : localePath(bookingLinkUrl(resource.slug, link.slug), locale);
46
53
  const q = (patch: Record<string, string | number | undefined>) => {
47
54
  const p = new URLSearchParams();
48
- for (const k of ['len', 'm', 'date', 'start', 'reschedule']) {
55
+ // an embed keeps its ?lang= (embeds are never under /es)
56
+ for (const k of ['len', 'm', 'date', 'start', 'reschedule', ...(embed ? ['lang'] : [])]) {
49
57
  const v = k in patch ? patch[k] : url.searchParams.get(k) ?? undefined;
50
58
  if (v !== undefined && v !== '' && v !== null) p.set(k, String(v));
51
59
  }
@@ -62,10 +70,10 @@ const val = (k: string) => values[k] ?? '';
62
70
 
63
71
  <div class={`pc-bk${embed ? ' is-embed' : ''}`}>
64
72
  <aside class="pc-bk-info">
65
- {!embed && <a class="pc-bk-back" href={resourceUrl(resource.slug)}>‹ {resource.name}</a>}
66
- {embed && <span class="pc-bk-kicker">{resource.name}</span>}
67
- <h1 class="pc-bk-title">{link.name}</h1>
68
- {link.description && <p class="pc-bk-desc">{link.description}</p>}
73
+ {!embed && <a class="pc-bk-back" href={localePath(resourceUrl(resource.slug), locale)}>‹ {room}</a>}
74
+ {embed && <span class="pc-bk-kicker">{room}</span>}
75
+ <h1 class="pc-bk-title">{words.name}</h1>
76
+ {words.description && <p class="pc-bk-desc">{words.description}</p>}
69
77
  <ul class="pc-bk-rules">{rules.map((r) => <li>{r}</li>)}</ul>
70
78
  {link.mode === 'range' && lengths.length > 1 && (
71
79
  <div class="pc-bk-lens" role="group" aria-label="How long">
@@ -111,7 +119,7 @@ const val = (k: string) => values[k] ?? '';
111
119
  ) : (
112
120
  <section class="pc-bk-form">
113
121
  <div class="pc-bk-summary">
114
- <b>{resource.name} · {link.name}</b>
122
+ <b>{room} · {words.name}</b>
115
123
  <span>{f.dayLong.format(chosen.start)} · {slotLabel(chosen, link, f)}</span>
116
124
  <a href={q({ start: undefined })}>Change</a>
117
125
  </div>
@@ -0,0 +1,40 @@
1
+ // Confirmed room bookings as schedule items: /calendar, embeds and TV boards
2
+ // (lib/calendar/data.ts) and the app API's `bookings` (lib/mobile/calendar.ts).
3
+ // Publicly a booking is named by its booking link in the reader's language
4
+ // ("Rehearsal", "Ensayo"; the calendar's "Reserved" when the link is gone)
5
+ // and never says who booked: no name, email, phone, notes, answers or manage
6
+ // token, and not the booking's id (publicBookingKey stands in for it).
7
+ import type { ContentRepository } from '../repo/types';
8
+ import { linkWords, type Booking, type BookingLink, type Resource } from '../schema';
9
+ import { sha256Hex } from '../api-keys';
10
+
11
+ export interface ScheduleBooking { booking: Booking; resource: Resource; link: BookingLink | null }
12
+
13
+ /** Confirmed bookings of these rooms overlapping [from, to), with their room and booking link, by start. */
14
+ export async function scheduleBookings(repo: ContentRepository, rooms: Resource[], from: number, to: number, limit = 5000): Promise<ScheduleBooking[]> {
15
+ if (!rooms.length) return [];
16
+ const byId = new Map(rooms.map((r) => [r.id, r]));
17
+ const [booked, links] = await Promise.all([
18
+ repo.listBookings({ from, to, statuses: ['confirmed'], kinds: ['booking'] }),
19
+ repo.listBookingLinks(),
20
+ ]);
21
+ const linkById = new Map(links.map((l) => [l.id, l]));
22
+ const out: ScheduleBooking[] = [];
23
+ for (const b of booked) {
24
+ const resource = byId.get(b.resourceId);
25
+ if (resource) out.push({ booking: b, resource, link: linkById.get(b.linkId) ?? null });
26
+ if (out.length >= limit) break;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /** Whether a booking reads as all day: its link books whole days (without its link: a day long, give or take a clock change). */
32
+ export const bookingAllDay = (b: Pick<Booking, 'start' | 'end'>, link: Pick<BookingLink, 'mode'> | null) =>
33
+ link ? link.mode === 'day' : b.end - b.start >= 86_400_000 - 3_600_000;
34
+
35
+ /** A booking's public title: its link's name in `locale`, else `reserved` (the calendar's word in that language). */
36
+ export const publicBookingTitle = (link: BookingLink | null, locale: string | null | undefined, reserved: string) =>
37
+ (link && linkWords(link, locale).name) || reserved;
38
+
39
+ /** A stable key for a booking in public data that doesn't reveal its id. */
40
+ export const publicBookingKey = async (b: Pick<Booking, 'id'>) => `b${(await sha256Hex(`public-booking:${b.id}`)).slice(0, 24)}`;
@@ -6,6 +6,7 @@ import { collectResponse, displayAnswers, staffAnswers } from '../forms/service'
6
6
  import type { ContentRepository } from '../repo/types';
7
7
  import type { Booking, BookingLink, BookingStatus, CalEvent, Resource, Settings } from '../schema';
8
8
  import { roomName } from '../schema';
9
+ import { bookingTitle } from './view';
9
10
  import { loadFeed, expandVEvents } from '../calendar/ics';
10
11
  import { emitWebhook } from '../webhooks';
11
12
  import { parseFrom } from '../email/config';
@@ -109,8 +110,8 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
109
110
  const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
110
111
  return {
111
112
  resource, link, event: null as CalEvent | null, tz,
112
- // the room in the booking's language (its main name without one)
113
- title: [resource && roomName(resource, locale), link?.name].filter(Boolean).join(' · ') || 'Booking',
113
+ // the room and its booking link in the booking's language (their main names without one)
114
+ title: bookingTitle(resource, link, locale) || 'Booking',
114
115
  location: resource ? roomName(resource, locale) : '',
115
116
  when: whenText(b, tz, settings, link?.mode === 'day'),
116
117
  };
@@ -304,8 +305,11 @@ export async function createResourceBooking(
304
305
  const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now());
305
306
  if (args.source !== 'admin' && !isBookable(ctx, args.start, args.end)) throw new BookingError(409, 'That time was just taken or isn’t available. Please pick another.');
306
307
  const approval = linkApproval(link, settings);
308
+ // "the first booking from that email": only a confirmed booking (staff approved it, or it
309
+ // confirmed by itself) makes them returning; one still waiting, declined or cancelled doesn't.
310
+ // The address is all a booking without an account has to go on.
307
311
  const returning = approval.mode === 'rules' && approval.rules.firstTime
308
- ? (await repo.listBookings({ email: booker.email, statuses: ['pending', 'confirmed'], kinds: ['booking'] })).length > 0
312
+ ? (await repo.listBookings({ email: booker.email, statuses: ['confirmed'], kinds: ['booking'], limit: 1 })).length > 0
309
313
  : true;
310
314
  const status: BookingStatus = args.status ?? decideStatus(approval, { start: args.start, end: args.end, seats: 1, returning });
311
315
  const b = await repo.createBooking({
@@ -1,5 +1,6 @@
1
1
  // Display helpers for the public booking pages.
2
2
  import type { BookingLink, Resource, Settings } from '../schema';
3
+ import { linkWords, roomName } from '../schema';
3
4
  import { siteLocale } from '../dates';
4
5
  import { addDaysYmd, localMidnight, resourceZone, ymd, type Slot } from './availability';
5
6
 
@@ -52,3 +53,8 @@ export function todayIn(resource: Resource, settings: Settings, now: number) {
52
53
  const tz = resourceZone(resource, settings.timezone);
53
54
  return { tz, today: ymd(now, tz), midnight: localMidnight(ymd(now, tz), tz) };
54
55
  }
56
+
57
+ /** "Sanctuary · Rehearsal": the room and booking link in `locale` (each its main name without one); '' when both are gone. */
58
+ export function bookingTitle(resource: Resource | null | undefined, link: BookingLink | null | undefined, locale?: string | null): string {
59
+ return [resource && roomName(resource, locale), link && linkWords(link, locale).name].filter(Boolean).join(' · ');
60
+ }
@@ -7,7 +7,8 @@
7
7
  // details, lib/display: published events only, private calendars when it
8
8
  // names them, bookings with the booker's name, never emails, phones or notes).
9
9
  import type { ContentRepository } from '../repo/types';
10
- import { roomColor, roomName, type BookingLink, type CalEvent, type Calendar, type EventLocation, type Resource, type Settings } from '../schema';
10
+ import { linkWords, roomColor, roomName, type CalEvent, type Calendar, type EventLocation, type Resource, type Settings } from '../schema';
11
+ import { bookingAllDay, publicBookingTitle, scheduleBookings } from '../booking/public';
11
12
  import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } from './events';
12
13
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
13
14
  import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
@@ -48,13 +49,14 @@ export interface ViewScope {
48
49
  includeUncategorized?: boolean;
49
50
  // feeds are network calls — views that don't need them can skip
50
51
  includeFeeds?: boolean;
51
- // "Reserved" blocks from resources set to show on the calendar
52
+ // confirmed bookings of rooms set to show on the calendar (named by their
53
+ // booking link; with the booker's name for staff and private screens)
52
54
  includeBookings?: boolean;
53
55
  // the address's cal/tag/notag/room (filter.ts); its calendars narrow
54
56
  // calendarIds when both are given
55
57
  filter?: ResolvedEventFilter;
56
- // the request's language (requestLocale): translated titles, event links
57
- // under its /<locale> prefix, and "Reserved" in its words
58
+ // the request's language (requestLocale): translated titles, room and
59
+ // booking link names, event links under its /<locale> prefix
58
60
  locale?: string;
59
61
  }
60
62
 
@@ -158,36 +160,28 @@ export async function collectViewEvents(
158
160
  events.push(...results.flat());
159
161
  }
160
162
 
161
- // resources marked "show on calendar": confirmed bookings appear as
162
- // Reserved blocks (no guest names on public views) — only on unscoped views.
163
- // A screen also shows the bookings of the rooms it names, as "<booking
164
- // type> · <booker>" (the name only: never their email, phone or notes).
163
+ // rooms marked "show on calendar": their confirmed bookings appear named by
164
+ // their booking link in the view's language ("Rehearsal", "Ensayo"; "Reserved"
165
+ // when the link is gone), never who booked, on views that name no calendar
166
+ // (a booking belongs to none): unfiltered, or by room (each in its own room),
167
+ // never with a tag. A screen also shows the bookings of the rooms it names, as
168
+ // "<booking link> · <booker>" (the name only: never their email, phone or notes).
165
169
  if (!requested && (scope.includeBookings ?? true) && CALENDAR.bookings) {
166
170
  const named = scope.audience === 'screen' ? f?.roomIds ?? [] : [];
167
171
  const resources = (await repo.listResources()).filter((r) => (r.showOnCalendar || named.includes(r.id)) && (!f || untaggedMatches(f, { roomId: r.id })));
168
- if (resources.length) {
169
- const [booked, links] = await Promise.all([
170
- repo.listBookings({ from: win.windowStartMs, to: win.windowEndMs, statuses: ['confirmed'], kinds: ['booking'] }),
171
- scope.audience === 'screen' ? repo.listBookingLinks() : Promise.resolve([] as BookingLink[]),
172
- ]);
173
- const byId = new Map(resources.map((r) => [r.id, r]));
174
- const linkName = new Map(links.map((l): [string, string] => [l.id, l.name]));
175
- for (const b of booked) {
176
- const r = byId.get(b.resourceId);
177
- if (!r) continue;
178
- const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
179
- // the room in the view's language (its main name without one)
180
- const room = roomName(r, scope.locale);
181
- const title = scope.audience === 'admin' ? `${r.name} · ${b.name}`
182
- : scope.audience === 'screen' ? [linkName.get(b.linkId) || room, b.name].filter(Boolean).join(' · ')
183
- : `${words.reserved} · ${room}`;
184
- events.push({
185
- start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
186
- title, location: room, description: '', cal: inScope.length, color: '#8e919a', calendarName: room,
187
- url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
188
- rooms: [{ id: r.id, slug: r.slug, name: room, color: roomColor(r) }],
189
- });
190
- }
172
+ for (const { booking: b, resource: r, link } of await scheduleBookings(repo, resources, win.windowStartMs, win.windowEndMs)) {
173
+ const allDay = bookingAllDay(b, link);
174
+ // the room and link in the view's language (their main names without one)
175
+ const room = roomName(r, scope.locale);
176
+ const title = scope.audience === 'admin' ? `${r.name} · ${b.name}`
177
+ : scope.audience === 'screen' ? [link ? linkWords(link, scope.locale).name : room, b.name].filter(Boolean).join(' · ')
178
+ : publicBookingTitle(link, scope.locale, words.reserved);
179
+ events.push({
180
+ start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
181
+ title, location: room, description: '', cal: inScope.length, color: '#8e919a', calendarName: room,
182
+ url: scope.audience === 'public' ? localePath(resourceUrl(r.slug), scope.locale ?? '') : undefined,
183
+ rooms: [{ id: r.id, slug: r.slug, name: room, color: roomColor(r) }],
184
+ });
191
185
  }
192
186
  }
193
187
 
@@ -398,8 +398,8 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
398
398
 
399
399
  // ---- visibility ----------------------------------------------------------
400
400
  // An event with no calendars counts as public. Otherwise it follows its most
401
- // open calendar: any public/unlisted calendar makes the event page public;
402
- // only public calendars put it on the main calendar page and site feed.
401
+ // open calendar: any public/unlisted calendar makes the event page public (and
402
+ // searchable); only public calendars put it on the main calendar page and site feed.
403
403
 
404
404
  export function eventIsPublic(e: CalEvent, calendars: Map<string, Calendar>): boolean {
405
405
  if (e.status !== 'published') return false;
@@ -416,6 +416,16 @@ export function eventIsListed(e: CalEvent, calendars: Map<string, Calendar>): bo
416
416
  return e.calendarIds.some((id) => calendars.get(id)?.visibility === 'public');
417
417
  }
418
418
 
419
+ /** The internal tag on appointments booked through a Google booking page (they come in titled "Booked"). */
420
+ export const BOOKING_TAG = '#google-booking';
421
+ /** A Google booking-page appointment: search never finds it and push never announces it. */
422
+ export const isGoogleBooking = (e: Pick<CalEvent, 'tags'>) => e.tags.some((t) => t.trim().toLowerCase() === BOOKING_TAG);
423
+
424
+ /** What search finds, on the website and in the app: events with a public page (unlisted calendars too), but no booking-page appointments. */
425
+ export function eventIsSearchable(e: CalEvent, calendars: Map<string, Calendar>): boolean {
426
+ return eventIsPublic(e, calendars) && !isGoogleBooking(e);
427
+ }
428
+
419
429
  // ---- formatting ----------------------------------------------------------
420
430
 
421
431
  export function formatOccurrence(
@@ -27,7 +27,7 @@ const DAY = 86_400_000;
27
27
  const pad = (n: number) => String(n).padStart(2, '0');
28
28
 
29
29
  /** The internal tag on booked appointments from Google booking pages (Q16): never shown publicly. */
30
- export const BOOKING_TAG = '#google-booking';
30
+ export { BOOKING_TAG } from '../events';
31
31
 
32
32
  /** The few words the sync writes, in the site's main language (Q9). */
33
33
  export interface SyncWords { cancelled: string; details: string; link: string; booked: string; untitled: string }
@@ -1,7 +1,9 @@
1
1
  // One TV board, ready to render: which days it covers, which events it may
2
2
  // show, and when it goes stale. Quick links (/display/calendar?…) and saved
3
3
  // screens without private details show what calendar.ics shows: published
4
- // events in public calendars (or an unlisted one named in cal=). A saved
4
+ // events in public calendars (or an unlisted one named in cal=), plus, as
5
+ // /calendar shows them, the bookings of rooms set to show on the calendar,
6
+ // named by their booking link (never who booked). A saved
5
7
  // screen with private details on also shows private calendars it names, the
6
8
  // bookings of its rooms with the booker's name, the rooms and hosts of every
7
9
  // event. Never emails, phone numbers, notes or RSVP lists.
@@ -108,9 +110,9 @@ export async function loadDisplayBoard(repo: ContentRepository, site: Settings,
108
110
  const { events, calendars: shownCalendars } = await collectViewEvents(repo, site, win, {
109
111
  audience: req.privateDetails ? 'screen' : 'public',
110
112
  filter,
111
- // quick links show what calendar.ics shows: no subscribed feeds, no bookings
113
+ // quick links show what calendar.ics shows (no subscribed feeds), and
114
+ // bookings as /calendar shows them: named by their link, never who booked
112
115
  includeFeeds: req.privateDetails,
113
- includeBookings: req.privateDetails,
114
116
  // translated titles in the display's language
115
117
  locale: lang,
116
118
  });
@@ -263,6 +263,8 @@ export const LinkInput = z.object({
263
263
  confirmMessage: z.string().max(1000).optional(), redirectUrl: z.string().max(1000).refine((v) => !v || /^https?:\/\//.test(v), 'Use an http(s) URL.').optional(),
264
264
  active: z.boolean().optional(),
265
265
  changeCutoff: z.number().int().min(0).max(8760).optional(),
266
+ // its name and description in the site's other languages: { es: { name: 'Ensayo' } } ('' = the main one)
267
+ translations: z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), z.object({ name: z.string().trim().max(120).default(''), description: z.string().max(2000).default('') })).optional(),
266
268
  }).strict();
267
269
  export const LinkPatch = LinkInput.partial();
268
270
 
@@ -1,10 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import { type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
3
+ import { linkWords, roomName, 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
6
  import { calendarToJson, eventToJson, roomsToJson } from '../calendar/api';
7
- import { eventIsListed, eventIsPublic, expandEvents, featuredOccurrence, type Occurrence } from '../calendar/events';
7
+ import { eventIsListed, eventIsPublic, eventIsSearchable, expandEvents, featuredOccurrence, type Occurrence } from '../calendar/events';
8
8
  import { availabilityContext } from '../booking/service';
9
9
  import { roomBusy } from '../booking/holds';
10
10
  import { sha256Hex } from '../api-keys';
@@ -19,6 +19,8 @@ import { localeQuery, mobileLocale, localizedUrl } from './locale';
19
19
  import { confirmsInstantly, linkApproval } from '../booking/approval';
20
20
  import { changeTopics } from '../push/service';
21
21
  import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../calendar/filter';
22
+ import { bookingAllDay, publicBookingKey, publicBookingTitle, scheduleBookings } from '../booking/public';
23
+ import { eventStrings } from '../calendar/event-words';
22
24
 
23
25
  export function requireCalendar(bookings = false) {
24
26
  if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
@@ -40,7 +42,8 @@ export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: i
40
42
  calendar: filterList('Only events in these calendars (slugs).'), cal: filterList('Alias of calendar.'),
41
43
  tag: filterList('Only events with one of these tags (a name in any case, or a slug).'), excludeTag: filterList('Leave out events with any of these tags.'), notag: filterList('Alias of excludeTag.'),
42
44
  room: filterList('Only events held in one of these rooms (slugs of rooms shown on public events).'),
43
- q: z.string().max(100).optional() }).strict();
45
+ bookings: z.enum(['1', 'true']).optional().describe('Also the confirmed bookings of rooms set to “Show on the calendar”, as `bookings` (with the first page): the booking link’s name in the requested language, the time and the room, never who booked. Only `room` narrows them; `calendar`, `tag` and `excludeTag` don’t (a booking belongs to no calendar and has no tags).'),
46
+ q: z.string().max(100).optional().describe('Search: the events /search finds (unlisted calendars too, never Google booking-page appointments), listed by date. Without it, only public calendars and the ones named in calendar.') }).strict();
44
47
  export async function listEvents(repo: ContentRepository, url: URL) {
45
48
  requireCalendar();
46
49
  const q = eventsQuery.parse(queryParams(url, FILTER_LISTS));
@@ -65,7 +68,9 @@ export async function listEvents(repo: ContentRepository, url: URL) {
65
68
  if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Room not found.');
66
69
  const picked = f.calendarIds;
67
70
  const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || picked?.includes(c.id)).map(c => [c.id, c]));
68
- const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (picked ? e.calendarIds.some(id => picked.includes(id)) && eventIsPublic(e, all) : eventIsListed(e, all))
71
+ // q continues /search, so it finds what search finds (unlisted calendars too, no booking-page appointments)
72
+ const shown = (e: CalEvent) => (term ? eventIsSearchable(e, all) : picked ? eventIsPublic(e, all) : eventIsListed(e, all));
73
+ const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (!picked || e.calendarIds.some(id => picked.includes(id))) && shown(e)
69
74
  && tagsMatch(f, e.tags)
70
75
  && (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
71
76
  const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to })
@@ -78,11 +83,29 @@ export async function listEvents(repo: ContentRepository, url: URL) {
78
83
  locale, timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
79
84
  occurrences: occurrences.slice(start, start + q.limit).map(o => ({
80
85
  ...occurrenceDto(o, resources, locale || siteLang(settings)),
81
- event: publicEventDto(o.event, origin, publicCalendars, settings, locale, resources),
86
+ event: publicEventDto(o.event, origin, publicCalendars, all, settings, locale, resources),
82
87
  })),
88
+ // the whole window's bookings come with the first page, so paging never repeats them
89
+ ...(q.bookings ? { bookings: q.page === 1 && bookingsEnabled() ? await publicBookingsDto(repo, [...resources.values()], f.roomIds, from, to, term, locale || siteLang(settings)) : [] } : {}),
83
90
  pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
84
91
  };
85
92
  }
93
+ /**
94
+ * Confirmed bookings of rooms set to show on the calendar (in `roomIds` when
95
+ * the address names rooms; `term` matches their title), as the public sees
96
+ * them on /calendar: the booking link's name in `locale`, the time and the
97
+ * room. Never who booked, and not the booking's id (`id` is an opaque key).
98
+ */
99
+ async function publicBookingsDto(repo: ContentRepository, rooms: Resource[], roomIds: string[] | null, from: number, to: number, term: string, locale: string) {
100
+ const shown = rooms.filter(r => r.showOnCalendar && (!roomIds || roomIds.includes(r.id)));
101
+ const reserved = eventStrings(locale).calendar.reserved;
102
+ const iso = (n: number) => new Date(n).toISOString();
103
+ const items = (await scheduleBookings(repo, shown, from, to, 500)).map(({ booking: b, resource: r, link }) => ({ b, r, title: publicBookingTitle(link, locale, reserved), allDay: bookingAllDay(b, link) }))
104
+ .filter(x => !term || x.title.toLowerCase().includes(term.toLowerCase()));
105
+ return Promise.all(items.map(async ({ b, r, title, allDay }) => ({
106
+ id: await publicBookingKey(b), title, start: iso(b.start), end: iso(b.end), allDay, rooms: roomsToJson([r.id], new Map([[r.id, r]]), true, locale),
107
+ })));
108
+ }
86
109
  // one event by slug (or id), whatever its date: apps open it from a link, a
87
110
  // push or an announcement, often weeks ahead. `on` ('YYYY-MM-DD', like the
88
111
  // website's ?on=) picks that day's occurrence; otherwise the next one, or the
@@ -102,7 +125,7 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
102
125
  return {
103
126
  locale,
104
127
  occurrence: o ? occurrenceDto(o, resources, locale || siteLang(settings)) : null,
105
- event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale, resources),
128
+ event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, all, settings, locale, resources),
106
129
  };
107
130
  }
108
131
  /** every room by id, for naming the public ones (Resource.showOnEvents) */
@@ -116,8 +139,9 @@ export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>, l
116
139
  location: o.location ? { name: o.location.name, address: o.location.address, url: o.location.url } : null,
117
140
  rooms: roomsToJson(o.roomIds, resources, false, locale) };
118
141
  }
119
- // e is already localized; publicCalendars limits which calendars are named
120
- export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null, resources?: Map<string, Resource>) {
142
+ // e is already localized; publicCalendars limits which calendars are named,
143
+ // `all` holds every calendar (their visibility decides which changes notify)
144
+ export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, all: Map<string, Calendar>, settings: Settings, locale: string | null, resources?: Map<string, Resource>) {
121
145
  // the repeat line in the request's language, else the site's main one
122
146
  const event = eventToJson(e, origin, publicCalendars, settings.timezone, { resources, locale: locale || siteLang(settings) });
123
147
  event.url = localizedUrl(event.url, origin, locale);
@@ -126,20 +150,23 @@ export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map
126
150
  event.image = safeUrl(e.cover, origin);
127
151
  event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
128
152
  // which push topics hear about date, time or place changes ([] = none do)
129
- return { ...event, notify: { changes: changeTopics(e, settings, publicCalendars) } };
153
+ return { ...event, notify: { changes: changeTopics(e, settings, all) } };
130
154
  }
155
+ export const resourcesQuery = z.object({ ...pageQuery, locale: localeQuery }).strict();
131
156
  export async function listResources(repo: ContentRepository, url: URL) {
132
157
  requireCalendar(true);
133
- const q = z.object(pageQuery).strict().parse(queryParams(url));
158
+ const q = resourcesQuery.parse(queryParams(url));
159
+ // rooms and booking links named in the request's language (their main words without one)
160
+ const locale = mobileLocale(q.locale);
134
161
  const [settings, resources, links] = await Promise.all([repo.getSettings(), repo.listResources(), repo.listBookingLinks()]);
135
162
  const origin = siteOrigin(settings, url), start = (q.page - 1) * q.limit;
136
163
  const visible = resources.filter(r => r.visibility === 'public');
137
164
  return { resources: visible.slice(start, start + q.limit).map(r => ({
138
- id: r.id, slug: r.slug, name: r.name, kind: r.kind, description: r.description, capacity: r.capacity, amenities: r.amenities,
139
- photo: safeUrl(r.photo, origin), timezone: r.timezone || settings.timezone || 'UTC', url: origin + resourceUrl(r.slug),
165
+ id: r.id, slug: r.slug, name: roomName(r, locale), kind: r.kind, description: r.description, capacity: r.capacity, amenities: r.amenities,
166
+ photo: safeUrl(r.photo, origin), timezone: r.timezone || settings.timezone || 'UTC', url: localizedUrl(origin + resourceUrl(r.slug), origin, locale),
140
167
  links: links.filter(l => l.resourceId === r.id && l.active && l.visibility === 'public').map(l => ({
141
- id: l.id, slug: l.slug, name: l.name, description: l.description, mode: l.mode, lengths: linkLengths(l), rules: describeLink(l),
142
- askPhone: l.askPhone, autoConfirm: confirmsInstantly(linkApproval(l, settings)), url: origin + bookingLinkUrl(r.slug, l.slug),
168
+ id: l.id, slug: l.slug, ...linkWords(l, locale), mode: l.mode, lengths: linkLengths(l), rules: describeLink(l),
169
+ askPhone: l.askPhone, autoConfirm: confirmsInstantly(linkApproval(l, settings)), url: localizedUrl(origin + bookingLinkUrl(r.slug, l.slug), origin, locale),
143
170
  questions: l.questions.map(({ id, label, type, required, options }) => ({ id, label, type, required, options })),
144
171
  })),
145
172
  })), pagination: { page: q.page, limit: q.limit, nextPage: visible.length > start + q.limit ? q.page + 1 : null } };
@@ -5,7 +5,8 @@ import { ApiError, readJson } from '../api';
5
5
  import { sha256Hex } from '../api-keys';
6
6
  import { randomToken, safeEqual } from '../editor/auth';
7
7
  import { CALENDAR, bookingsEnabled } from '../calendar/config';
8
- import { bookingManagePath } from '../booking/emails';
8
+ import { bookingLanguage, bookingManagePath } from '../booking/emails';
9
+ import { bookingTitle } from '../booking/view';
9
10
  import { encrypt, encryptionAvailable, resolveConfig, validAddress } from '../email/config';
10
11
  import { kickEmail } from '../email/service';
11
12
  import { guestChanges } from '../booking/service';
@@ -144,7 +145,8 @@ export async function myBookings(c: PublicContext) {
144
145
  return {
145
146
  id: b.id, reference: b.id.slice(0, 8).toUpperCase(), kind: b.kind as 'booking' | 'rsvp', status: b.status,
146
147
  start: new Date(b.start).toISOString(), end: new Date(b.end).toISOString(), seats: b.seats,
147
- title: event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
148
+ // a room booking in the language it was booked in, as its emails name it
149
+ title: event ? event.title : bookingTitle(resource, link, bookingLanguage(b, settings).locale) || 'Booking',
148
150
  // use with the Booking-Token header on /bookings/manage/*
149
151
  manageToken: b.manageToken, manageUrl: origin + bookingManagePath(b),
150
152
  can: guestChanges(b, link, now),
@@ -5,6 +5,8 @@ import { bookingsEnabled, eventUrl } from '../calendar/config';
5
5
  import { eventTimeZone, locationLine } from '../calendar/events';
6
6
  import { resourceZone, linkLengths } from '../booking/availability';
7
7
  import { bookingIcs, bookingLanguage, bookingManagePath } from '../booking/emails';
8
+ import { bookingTitle } from '../booking/view';
9
+ import { linkWords, roomName } from '../schema';
8
10
  import { localePath } from '../i18n';
9
11
  import { confirmOffer, guestCancel, guestChanges, guestMoveLink, guestReschedule, waitlistPosition } from '../booking/service';
10
12
  import { siteOrigin } from '../seo';
@@ -48,6 +50,8 @@ async function managed(c: PublicContext): Promise<Managed> {
48
50
  function view(m: Managed, booking = m.booking, position: number | null = null) {
49
51
  const { resource, link, event, settings, origin } = m;
50
52
  const can = guestChanges(booking, link);
53
+ // the room and booking link named in the language it was booked in, as its emails name them
54
+ const { locale } = bookingLanguage(booking, settings);
51
55
  return {
52
56
  booking: {
53
57
  id: booking.id, reference: booking.id.slice(0, 8).toUpperCase(), kind: booking.kind as 'booking' | 'rsvp', status: booking.status,
@@ -59,10 +63,10 @@ function view(m: Managed, booking = m.booking, position: number | null = null) {
59
63
  // in line: its place (1 = next); a held spot: until when
60
64
  position, holdUntil: booking.status === 'offered' && booking.holdUntil !== null ? new Date(booking.holdUntil).toISOString() : null,
61
65
  },
62
- title: event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
63
- location: event ? locationLine(event.location) : resource?.name ?? '',
64
- resource: resource ? { slug: resource.slug, name: resource.name } : null,
65
- link: link ? { slug: link.slug, name: link.name, mode: link.mode, lengths: linkLengths(link) } : null,
66
+ title: event ? event.title : bookingTitle(resource, link, locale) || 'Booking',
67
+ location: event ? locationLine(event.location) : resource ? roomName(resource, locale) : '',
68
+ resource: resource ? { slug: resource.slug, name: roomName(resource, locale) } : null,
69
+ link: link ? { slug: link.slug, name: linkWords(link, locale).name, mode: link.mode, lengths: linkLengths(link) } : null,
66
70
  // the web pages in the language it was booked in, as the emails link them
67
71
  event: event ? { slug: event.slug, title: event.title, url: origin + localePath(eventUrl(event.slug), bookingLanguage(booking, settings).locale) } : null,
68
72
  // the web page for this booking (the same private link the emails carry)
@@ -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, roomFreeQuery } from './calendar';
5
+ import { eventsQuery, eventDetailQuery, availabilityQuery, roomFreeQuery, resourcesQuery } from './calendar';
6
6
  import { bookingInput } from './bookings';
7
7
  import { rsvpQuery } from './rsvp';
8
8
  import { pagesQuery } from './pages';
@@ -15,7 +15,7 @@ import { codeInput, tokenInput, myBookingsQuery } from './guest';
15
15
  import { deviceInput, deviceUpdate, staffLinkInput } from '../push/api';
16
16
  import { announcementsQuery } from '../announcements/api';
17
17
  import { localeOnlyQuery } from './locale';
18
- import { pageQuery, queryParams } from './http';
18
+ import { queryParams } from './http';
19
19
  import * as schemas from './schemas';
20
20
  import { editorPaths, editorSchemas, editorSecuritySchemes } from '../editor/openapi';
21
21
 
@@ -52,11 +52,11 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
52
52
  ['/content', 'Published content in the requested language; q (requires search) returns ranked matches, posts before pages, up to 200 per kind', contentQuery, jsonSchema(z.object({ locale: z.string().nullable(), items: z.array(schemas.ContentSummary), pagination: schemas.Pagination }))],
53
53
  ['/content/{id}', 'Published content detail', contentDetailQuery, ref('Content')],
54
54
  ['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
55
- ['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
55
+ ['/events', 'Public event occurrences; bookings=1 adds the confirmed bookings of rooms shown on the calendar (link name, time, room; never who booked); requires calendar', eventsQuery, ref('Events')],
56
56
  ['/events/{slug}', 'One public event by slug or id, whatever its date, with its next occurrence (or the one on `on`, YYYY-MM-DD; the most recent once it is over); requires calendar', eventDetailQuery, ref('EventDetail')],
57
57
  ['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
58
58
  ['/events/{slug}/rsvp', 'RSVP status of one date (start = the occurrence start exactly): open or why not, live seats, the questions and their version; never cached; requires calendar', rsvpQuery, ref('RsvpStatus'), false],
59
- ['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
59
+ ['/resources', 'Public resources and booking links, named in the requested language (locale: their translated names and descriptions); requires bookings', resourcesQuery, ref('Resources')],
60
60
  ['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
61
61
  ['/push/topics', 'Topics an app can offer as notification toggles, named in the requested language; requires push', localeOnlyQuery, ref('PushTopics')],
62
62
  ['/announcements', 'Announcements up right now, newest first, in the requested language; pinned=true lists only the banners, urgent first; requires announcements', announcementsQuery, ref('Announcements')],
@@ -50,8 +50,13 @@ export const Event = CalEvent.pick({
50
50
  // push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
51
51
  notify: z.object({ changes: strings }),
52
52
  });
53
+ // a confirmed room booking as the public sees it (bookings=1): its booking link's name in the requested
54
+ // language ("Reserved" without one), the time and the room; never who booked. id: an opaque key, not the booking's
55
+ export const ScheduleBooking = z.object({ id: str, title: str, start: date, end: date, allDay: bool, rooms: z.array(EventRoom) });
53
56
  export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
54
57
  occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, location: OccurrencePlace, rooms: z.array(EventRoom), event: Event })),
58
+ // only with bookings=1: the window's bookings, on the first page ([] on later pages)
59
+ bookings: z.array(ScheduleBooking).optional(),
55
60
  });
56
61
  const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
57
62
  const ContentHit = ContentSummary.extend({ snippet: Snippet });
@@ -125,7 +130,7 @@ export const GuestBookings = z.object({ email: str, pagination: Pagination, item
125
130
  export const GuestSignedOut = z.object({ signedOut: z.literal(true) });
126
131
  export const Calendars = z.object({ calendars: z.array(Calendar.pick({ id: true, slug: true, name: true, color: true, description: true, kind: true, visibility: true }).extend({ feedUrl: str })) });
127
132
  export const Resources = z.object({ pagination: Pagination, resources: z.array(z.object({
128
- id: str, slug: str, name: str, kind: str, description: str, capacity: z.number(), amenities: strings, photo: str, timezone: str, url: str,
133
+ id: str, slug: str, name: str, kind: str, description: str, capacity: z.number().nullable(), amenities: strings, photo: str, timezone: str, url: str,
129
134
  links: z.array(z.object({ id: str, slug: str, name: str, description: str, mode: z.enum(['slots', 'day', 'range']), lengths: z.array(z.number()), rules: str,
130
135
  askPhone: bool, autoConfirm: bool, url: str, questions: z.array(BookingQuestion.omit({ help: true })),
131
136
  })),
@@ -6,7 +6,7 @@ import { siteOrigin } from '../seo';
6
6
  import { siteLang } from '../dates';
7
7
  import { SEARCH } from '../search-config';
8
8
  import { CALENDAR } from '../calendar/config';
9
- import { eventIsListed, featuredOccurrence } from '../calendar/events';
9
+ import { eventIsSearchable, featuredOccurrence } from '../calendar/events';
10
10
  import { localizeDoc } from '../translations';
11
11
  import { ApiError } from '../api';
12
12
  import { contentDto } from './content';
@@ -68,12 +68,13 @@ export async function search(repo: ContentRepository, url: URL) {
68
68
  const pages = want.has('page') && !q.collection ? found.pages : [];
69
69
  const all = new Map(calendars.map((c) => [c.id, c]));
70
70
  const listed = new Map(calendars.filter((c) => c.visibility === 'public').map((c) => [c.id, c]));
71
- let events = want.has('event') && CALENDAR.enabled && !q.collection ? found.events.filter(({ doc }) => eventIsListed(doc, all)) : [];
72
- // the index ranks before visibility is known: if unlisted events filled the
73
- // first batch, look deeper once (the repo's 200-row cap) for listed ones
71
+ // the website's rule: any calendar that isn't private, no booking-page appointments
72
+ let events = want.has('event') && CALENDAR.enabled && !q.collection ? found.events.filter(({ doc }) => eventIsSearchable(doc, all)) : [];
73
+ // the index ranks before visibility is known: if private events or bookings
74
+ // filled the first batch, look deeper once (the repo's 200-row cap)
74
75
  if (want.has('event') && CALENDAR.enabled && !q.collection && events.length <= q.limit && found.events.length >= q.limit + 1) {
75
76
  const deeper = await repo.searchPublished(q.q, { locale: normalizePageLocale(locale ?? ''), limit: 200, collections: [] });
76
- events = deeper.events.filter(({ doc }) => eventIsListed(doc, all));
77
+ events = deeper.events.filter(({ doc }) => eventIsSearchable(doc, all));
77
78
  }
78
79
  const now = Date.now();
79
80
  return {
@@ -87,7 +88,7 @@ export async function search(repo: ContentRepository, url: URL) {
87
88
  snippet: plainSnippet(snippet),
88
89
  // the next occurrence, or the latest one when the event is over
89
90
  occurrence: o ? (({ id: _, ...rest }) => rest)(occurrenceDto(o, resources, locale || siteLang(settings))) : null,
90
- event: publicEventDto(e, origin, listed, settings, locale, resources),
91
+ event: publicEventDto(e, origin, listed, all, settings, locale, resources),
91
92
  };
92
93
  }),
93
94
  more: { posts: posts.length > q.limit, pages: pages.length > q.limit, events: events.length > q.limit },