@acms-subir/core 0.3.62 → 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.
@@ -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
 
@@ -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,6 +1,6 @@
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';
@@ -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,6 +42,7 @@ 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).'),
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).'),
43
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();
@@ -82,9 +85,27 @@ export async function listEvents(repo: ContentRepository, url: URL) {
82
85
  ...occurrenceDto(o, resources, locale || siteLang(settings)),
83
86
  event: publicEventDto(o.event, origin, publicCalendars, all, settings, locale, resources),
84
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)) : [] } : {}),
85
90
  pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
86
91
  };
87
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
+ }
88
109
  // one event by slug (or id), whatever its date: apps open it from a link, a
89
110
  // push or an announcement, often weeks ahead. `on` ('YYYY-MM-DD', like the
90
111
  // website's ?on=) picks that day's occurrence; otherwise the next one, or the
@@ -131,18 +152,21 @@ export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map
131
152
  // which push topics hear about date, time or place changes ([] = none do)
132
153
  return { ...event, notify: { changes: changeTopics(e, settings, all) } };
133
154
  }
155
+ export const resourcesQuery = z.object({ ...pageQuery, locale: localeQuery }).strict();
134
156
  export async function listResources(repo: ContentRepository, url: URL) {
135
157
  requireCalendar(true);
136
- 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);
137
161
  const [settings, resources, links] = await Promise.all([repo.getSettings(), repo.listResources(), repo.listBookingLinks()]);
138
162
  const origin = siteOrigin(settings, url), start = (q.page - 1) * q.limit;
139
163
  const visible = resources.filter(r => r.visibility === 'public');
140
164
  return { resources: visible.slice(start, start + q.limit).map(r => ({
141
- id: r.id, slug: r.slug, name: r.name, kind: r.kind, description: r.description, capacity: r.capacity, amenities: r.amenities,
142
- 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),
143
167
  links: links.filter(l => l.resourceId === r.id && l.active && l.visibility === 'public').map(l => ({
144
- id: l.id, slug: l.slug, name: l.name, description: l.description, mode: l.mode, lengths: linkLengths(l), rules: describeLink(l),
145
- 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),
146
170
  questions: l.questions.map(({ id, label, type, required, options }) => ({ id, label, type, required, options })),
147
171
  })),
148
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
  })),
@@ -748,6 +748,13 @@ export const MIGRATIONS: Migration[] = [
748
748
  `DROP TRIGGER content_version_resources_update`,
749
749
  contentVersionTriggers('resources', [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'])[2],
750
750
  ] },
751
+ // a booking link's name and description in other languages
752
+ // (BookingLink.translations): public, so a change refreshes cached pages and API answers
753
+ { version: 72, statements: [
754
+ `ALTER TABLE booking_links ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
755
+ `DROP TRIGGER content_version_booking_links_update`,
756
+ contentVersionTriggers('booking_links', [...CONTENT_VERSION_COLUMNS.booking_links, 'translations'])[2],
757
+ ] },
751
758
  ];
752
759
 
753
760
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -756,6 +763,7 @@ export const CURRENT_CONTENT_VERSION_COLUMNS = {
756
763
  settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
757
764
  events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'],
758
765
  resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'],
766
+ booking_links: [...CONTENT_VERSION_COLUMNS.booking_links, 'translations'],
759
767
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
760
768
  };
761
769
 
@@ -230,10 +230,15 @@ function rowToLink2(r: Row): BookingLink {
230
230
  hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
231
231
  approval: r.approval ?? 'default', approvalRules: json(r.approval_rules, {}),
232
232
  askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
233
- redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0),
233
+ redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0), translations: linkTranslations(r.translations),
234
234
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
235
235
  });
236
236
  }
237
+ // a booking link's words in other languages; a bad entry (or a broken column) reads as untranslated
238
+ function linkTranslations(raw: unknown): BookingLink['translations'] {
239
+ const parsed = BookingLink.shape.translations.safeParse(json(raw, {}));
240
+ return parsed.success ? parsed.data : {};
241
+ }
237
242
  function rowToBooking(r: Row): Booking {
238
243
  return Booking.parse({
239
244
  id: r.id, kind: r.kind ?? 'booking', resourceId: r.resource_id ?? '', linkId: r.link_id ?? '', eventId: r.event_id ?? '',
@@ -1175,11 +1180,11 @@ export class SqlRepository implements ContentRepository {
1175
1180
  return {
1176
1181
  sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
1177
1182
  buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
1178
- active, ord, created_at, updated_at, approval, approval_rules, change_cutoff) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1183
+ active, ord, created_at, updated_at, approval, approval_rules, change_cutoff, translations) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1179
1184
  args: [l.id, l.resourceId, l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step,
1180
1185
  l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
1181
1186
  l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt,
1182
- l.approval, JSON.stringify(l.approvalRules), l.changeCutoff],
1187
+ l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, JSON.stringify(l.translations)],
1183
1188
  };
1184
1189
  }
1185
1190
  private bookingInsert(b: Booking): SqlStatement {
@@ -1271,10 +1276,10 @@ export class SqlRepository implements ContentRepository {
1271
1276
  await this.driver.run(
1272
1277
  `UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
1273
1278
  buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
1274
- ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ?, change_cutoff = ? WHERE id = ?`,
1279
+ ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ?, change_cutoff = ?, translations = ? WHERE id = ?`,
1275
1280
  [l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
1276
1281
  l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
1277
- JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, id]
1282
+ JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, JSON.stringify(l.translations), id]
1278
1283
  );
1279
1284
  }
1280
1285
  async deleteBookingLink(id: string) {
package/src/lib/schema.ts CHANGED
@@ -584,6 +584,15 @@ export const DateOverride = z.object({
584
584
  });
585
585
  export type DateOverride = z.infer<typeof DateOverride>;
586
586
 
587
+ // a language a room or booking link has its own words in ('es', 'es-MX')
588
+ const LocaleKey = z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/);
589
+ // an entry for `locale` ('es-MX' → 'es' when it has no entry of its own)
590
+ const forLocale = <T>(all: Record<string, T> | undefined, locale?: string | null): T | undefined => {
591
+ if (!locale || !all) return undefined;
592
+ const want = locale.toLowerCase();
593
+ return all[want] ?? all[want.split('-')[0]];
594
+ };
595
+
587
596
  export const Resource = z.object({
588
597
  id: z.string(),
589
598
  slug: z.string(),
@@ -614,7 +623,7 @@ export const Resource = z.object({
614
623
  requestable: z.boolean().default(false),
615
624
  // its name in the site's other languages ({ es: { name: 'Salón de jóvenes' } });
616
625
  // roomName() picks one, else `name`. The slug stays the same in every language.
617
- translations: z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), z.object({ name: z.string().max(120).default('') })).default({}),
626
+ translations: z.record(LocaleKey, z.object({ name: z.string().max(120).default('') })).default({}),
618
627
  // secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
619
628
  feedToken: z.string().default(''),
620
629
  // the saved place this room is in ('' = none)
@@ -628,10 +637,7 @@ export type Resource = z.infer<typeof Resource>;
628
637
  export const roomColor = (r: Pick<Resource, 'color'>) => (/^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '');
629
638
  /** A room's name in `locale` ('es', 'es-MX' → its 'es' name), else its main name. */
630
639
  export function roomName(r: Pick<Resource, 'name'> & { translations?: Resource['translations'] }, locale?: string | null): string {
631
- if (!locale || !r.translations) return r.name;
632
- const want = locale.toLowerCase();
633
- const t = r.translations[want] ?? r.translations[want.split('-')[0]];
634
- return t?.name.trim() || r.name;
640
+ return forLocale(r.translations, locale)?.name.trim() || r.name;
635
641
  }
636
642
 
637
643
  // A saved place (HQ, the park pavilion…): picked in an event's Where field,
@@ -804,11 +810,19 @@ export const BookingLink = z.object({
804
810
  confirmMessage: z.string().default(''),
805
811
  redirectUrl: z.string().default(''),
806
812
  active: z.boolean().default(true),
813
+ // its name and description in the site's other languages ({ es: { name: 'Ensayo' } });
814
+ // linkWords() picks them, each falling back to the main one. The slug stays the same.
815
+ translations: z.record(LocaleKey, z.object({ name: z.string().max(120).default(''), description: z.string().max(2000).default('') })).default({}),
807
816
  order: z.number().default(0),
808
817
  createdAt: z.number(),
809
818
  updatedAt: z.number(),
810
819
  });
811
820
  export type BookingLink = z.infer<typeof BookingLink>;
821
+ /** A booking link's name and description in `locale` ('es-MX' → its 'es' words), each falling back to the main one. */
822
+ export function linkWords(l: Pick<BookingLink, 'name' | 'description'> & { translations?: BookingLink['translations'] }, locale?: string | null): { name: string; description: string } {
823
+ const t = forLocale(l.translations, locale);
824
+ return { name: t?.name.trim() || l.name, description: t?.description.trim() || l.description };
825
+ }
812
826
 
813
827
  // RSVPs only: waitlisted = in line for a full date (takes no seats); offered =
814
828
  // a freed spot held for them until holdUntil (takes its seats); expired = they
@@ -1,7 +1,7 @@
1
1
  import sanitizeHtml from 'sanitize-html';
2
2
  import type { z } from 'zod';
3
3
  import type { ContentRepository } from './repo/types';
4
- import { authorSlug, tagFilter, type Settings, type User } from './schema';
4
+ import { authorSlug, linkWords, roomName, tagFilter, type Settings, type User } from './schema';
5
5
  import { htmlToPlaintext } from './plaintext';
6
6
  import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
7
7
  import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
@@ -165,7 +165,9 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
165
165
  const links = (await repo.listBookingLinks()).filter((l) => l.active && l.visibility === 'public' && byId.has(l.resourceId));
166
166
  return links.slice(0, limit).map((l): BookingItem => {
167
167
  const r = byId.get(l.resourceId)!;
168
- return { title: l.name, url: bookingLinkUrl(r.slug, l.slug), description: l.description, resource: r.name, minutes: l.duration };
168
+ // the link and room in the page's language (their main words without one)
169
+ const words = linkWords(l, lang);
170
+ return { title: words.name, url: lp(bookingLinkUrl(r.slug, l.slug)), description: words.description, resource: roomName(r, lang), minutes: l.duration };
169
171
  });
170
172
  }
171
173
 
@@ -11,6 +11,7 @@ import { siteOrigin } from '../../../../lib/seo';
11
11
  import { APPROVAL_LABEL, linkApproval, siteApproval } from '../../../../lib/booking/approval';
12
12
  import { ApprovalRules } from '../../../../lib/schema';
13
13
  import { forbiddenUnless } from '../../../../lib/permissions/access';
14
+ import { extraLocales, localeName } from '../../../../lib/i18n';
14
15
 
15
16
  // One booking link: how people book, how bookings get approved (follow the
16
17
  // site setting, or its own mode + rules), when it's open (within the room or
@@ -69,8 +70,14 @@ if (Astro.request.method === 'POST') {
69
70
  deadlineHours: form.has('deadline') ? int(form.get('deadlineHours'), 1, 24 * 60, 48) : 0,
70
71
  deadlineAction: form.get('deadlineAction') === 'decline' ? 'decline' : 'approve',
71
72
  });
73
+ // its name and description in each of the site's other languages ('' = the main one); languages no longer configured keep theirs
74
+ const translations = { ...link.translations };
75
+ for (const l of extraLocales()) {
76
+ const words = { name: String(form.get('name_' + l) || '').trim().slice(0, 120), description: String(form.get('description_' + l) || '').trim().slice(0, 1000) };
77
+ if (words.name || words.description) translations[l] = words; else delete translations[l];
78
+ }
72
79
  const patch: Partial<BookingLink> = {
73
- name, slug, mode: mode as BookingMode,
80
+ name, slug, mode: mode as BookingMode, translations,
74
81
  description: String(form.get('description') || '').slice(0, 1000),
75
82
  duration,
76
83
  lengths,
@@ -221,8 +228,14 @@ const effective = linkApproval(view, settings).mode;
221
228
  <section class="card card-pad">
222
229
  <div class="card-head"><b>Link</b></div>
223
230
  <label class="field"><span>Name</span><input type="text" name="name" value={view.name} required /></label>
231
+ {extraLocales().map((l) => (
232
+ <label class="field"><span>Name in {localeName(l)} <em class="hint">optional; shown on {localeName(l)} pages, in the app and in emails</em></span><input type="text" name={'name_' + l} lang={l} maxlength="120" value={view.translations[l]?.name ?? ''} placeholder={view.name} /></label>
233
+ ))}
224
234
  <label class="field"><span>URL name</span><input type="text" class="mono" name="slug" value={view.slug} /></label>
225
235
  <label class="field"><span>Description</span><textarea name="description" rows="3" placeholder="What this booking is for">{view.description}</textarea></label>
236
+ {extraLocales().map((l) => (
237
+ <label class="field"><span>Description in {localeName(l)} <em class="hint">optional</em></span><textarea name={'description_' + l} lang={l} rows="3" maxlength="1000" placeholder={view.description}>{view.translations[l]?.description ?? ''}</textarea></label>
238
+ ))}
226
239
  <label class="field">
227
240
  <span>Who can find it</span>
228
241
  <select name="visibility">
@@ -7,11 +7,13 @@ import { availabilityContext, createResourceBooking, guestMoveLink, guestResched
7
7
  import { siteOrigin } from '../../../lib/seo';
8
8
  import { waitUntilFrom } from '../../../lib/webhooks';
9
9
  import { localePath, requestLocale } from '../../../lib/i18n';
10
+ import { linkWords, roomName } from '../../../lib/schema';
10
11
 
11
12
  // One booking link: pick → answer → done. POSTs create the booking (or move
12
13
  // one when ?reschedule=<token>) and land on the manage page. These pages are
13
- // in English, but a booking made from /es/… keeps the visitor's language for
14
- // its emails and its own page.
14
+ // in English, but the link and room are named in the page's language (their
15
+ // translations), every step stays under /es/…, and a booking made there keeps
16
+ // the visitor's language for its emails and its own page.
15
17
  if (!bookingsEnabled()) return new Response(null, { status: 404 });
16
18
  const repo = await getRepo(Astro.locals);
17
19
  const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
@@ -64,11 +66,14 @@ if (Astro.request.method === 'POST') {
64
66
  }
65
67
  }
66
68
  const ctx = await availabilityContext(repo, resource, reschedule ? guestMoveLink(link) : link, settings, Date.now(), reschedule?.id);
69
+ // the link and room named in the page's language
70
+ const words = linkWords(link, locale);
71
+ const room = roomName(resource, locale);
67
72
  ---
68
73
 
69
- <Site title={`${link.name} · ${resource.name}`} description={link.description || `Book ${resource.name}.`} noindex={link.visibility === 'unlisted' || resource.visibility === 'unlisted'}>
74
+ <Site title={`${words.name} · ${room}`} description={words.description || `Book ${room}.`} noindex={link.visibility === 'unlisted' || resource.visibility === 'unlisted'}>
70
75
  <div class="pc-bkpage">
71
- <BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={flowUrl} error={error} values={values} reschedule={reschedule} />
72
- <p class="pc-bk-foot"><a href={resourceUrl(resource.slug)}>Other ways to book {resource.name}</a></p>
76
+ <BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={flowUrl} error={error} values={values} reschedule={reschedule} locale={locale} />
77
+ <p class="pc-bk-foot"><a href={localePath(resourceUrl(resource.slug), locale)}>Other ways to book {room}</a></p>
73
78
  </div>
74
79
  </Site>