@acms-subir/core 0.3.3 → 0.3.4

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 (66) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/cli/preview.mjs +8 -4
  3. package/cli/sections.mjs +4 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/theme-authoring.md +19 -3
  6. package/integration.mjs +21 -4
  7. package/package.json +14 -12
  8. package/src/actions/index.ts +186 -3
  9. package/src/components/booking/BookingFlow.astro +6 -3
  10. package/src/components/calendar/BookingsHead.astro +51 -0
  11. package/src/components/calendar/CalendarBoard.tsx +618 -0
  12. package/src/components/calendar/MiniMonth.tsx +54 -0
  13. package/src/components/calendar/NewEventDialog.tsx +44 -0
  14. package/src/components/calendar/QuickAdd.tsx +263 -0
  15. package/src/components/calendar/RoomHolds.tsx +203 -0
  16. package/src/components/calendar/WhereItLands.tsx +51 -0
  17. package/src/components/editor/EventPanel.tsx +23 -2
  18. package/src/components/editor/PostEditor.tsx +32 -4
  19. package/src/layouts/Admin.astro +5 -6
  20. package/src/layouts/Site.astro +29 -2
  21. package/src/lib/backup/content.ts +6 -1
  22. package/src/lib/booking/agent.ts +3 -2
  23. package/src/lib/booking/approval.ts +70 -0
  24. package/src/lib/booking/holds.ts +120 -0
  25. package/src/lib/booking/service.ts +37 -3
  26. package/src/lib/calendar/agent-schema.ts +9 -1
  27. package/src/lib/calendar/agent.ts +16 -1
  28. package/src/lib/calendar/board.ts +171 -0
  29. package/src/lib/calendar/config.ts +3 -0
  30. package/src/lib/calendar/grid.ts +169 -0
  31. package/src/lib/calendar/migration.ts +16 -0
  32. package/src/lib/calendar/render.ts +9 -7
  33. package/src/lib/calendar/schedule.ts +166 -0
  34. package/src/lib/content-service.ts +5 -0
  35. package/src/lib/drafts.ts +1 -1
  36. package/src/lib/mobile/calendar.ts +10 -6
  37. package/src/lib/mobile/http.ts +6 -0
  38. package/src/lib/repo/migrations.ts +3 -0
  39. package/src/lib/repo/sql.ts +28 -16
  40. package/src/lib/repo/types.ts +2 -2
  41. package/src/lib/scheduler.ts +3 -1
  42. package/src/lib/schema.ts +49 -0
  43. package/src/lib/sections.ts +33 -8
  44. package/src/lib/site-chrome.ts +34 -0
  45. package/src/lib/theme-assets.ts +16 -0
  46. package/src/lib/theme.ts +17 -3
  47. package/src/middleware.ts +6 -2
  48. package/src/pages/admin/calendar/bookings.astro +32 -26
  49. package/src/pages/admin/calendar/calendars.astro +4 -263
  50. package/src/pages/admin/calendar/events/index.astro +41 -83
  51. package/src/pages/admin/calendar/index.astro +43 -0
  52. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  53. package/src/pages/admin/calendar/resources/index.astro +16 -17
  54. package/src/pages/admin/calendar/resources/link.astro +52 -12
  55. package/src/pages/admin/calendar/settings.astro +458 -0
  56. package/src/pages/admin/integrations.astro +5 -11
  57. package/src/pages/book/[resource].ics.ts +7 -1
  58. package/src/styles/admin.css +48 -0
  59. package/src/styles/cards.css +5 -1
  60. package/src/styles/editor.css +36 -0
  61. package/src/styles/schedule.css +325 -0
  62. package/src/styles/sections.css +19 -16
  63. package/src/theme-sdk/fields.mjs +5 -1
  64. package/src/theme-sdk/index.ts +5 -1
  65. package/src/virtual.d.ts +3 -0
  66. package/src/pages/admin/calendar/index.ts +0 -4
@@ -1,6 +1,8 @@
1
1
  import { useMemo, useState } from 'react';
2
2
  import type { EventCta, EventLocation } from '../../lib/schema';
3
3
  import type { EventFieldConfig } from '../../lib/calendar/config';
4
+ import WhereItLands from '../calendar/WhereItLands';
5
+ import RoomHolds, { type RoomHoldValue } from '../calendar/RoomHolds';
4
6
  import { buildRepeat, describeRepeat, parseRepeat, WEEKDAY_CODES, weekdayOf, type RepeatParts } from '../../lib/calendar/rrule';
5
7
 
6
8
  // The event half of the editor sidebar: When · Repeats · Where · Button ·
@@ -21,6 +23,7 @@ export interface EventMeta {
21
23
  calendarIds: string[];
22
24
  fields: Record<string, string>;
23
25
  rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number };
26
+ roomHold: RoomHoldValue;
24
27
  }
25
28
 
26
29
  export interface CalendarOption {
@@ -35,6 +38,11 @@ interface Props {
35
38
  calendars: CalendarOption[];
36
39
  fields: EventFieldConfig[];
37
40
  siteTimezone: string;
41
+ // shows the "where it lands" mini month (Schedule data, minus this event)
42
+ eventId?: string;
43
+ // for the Rooms section (conflict wording, "Hold now" on drafts)
44
+ title?: string;
45
+ status?: string;
38
46
  }
39
47
 
40
48
  const DAY_LETTERS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
@@ -64,7 +72,7 @@ function zoneList(): string[] {
64
72
  }
65
73
  }
66
74
 
67
- export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }: Props) {
75
+ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone, eventId, title = '', status = 'draft' }: Props) {
68
76
  const repeat = useMemo(() => parseRepeat(meta.rrule, meta.start), [meta.rrule, meta.start]);
69
77
  const [skipDraft, setSkipDraft] = useState('');
70
78
  const zones = useMemo(zoneList, []);
@@ -136,6 +144,10 @@ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }:
136
144
  {!meta.allDay && !showEndDate && (
137
145
  <button type="button" className="pe-ev-link" onClick={() => setShowEndDate(true)}>Ends on another day</button>
138
146
  )}
147
+ {eventId && datePart(meta.start) && (
148
+ <WhereItLands date={datePart(meta.start)} eventId={eventId}
149
+ onPick={(d) => setStart(meta.allDay ? d : `${d}T${timePart(meta.start) || '10:00'}`)} />
150
+ )}
139
151
  <label className="pe-switch-row">
140
152
  <span><b>All day</b><small>No start or end time</small></span>
141
153
  <input type="checkbox" role="switch" className="pe-switch" checked={meta.allDay} onChange={(e) => toggleAllDay(e.target.checked)} />
@@ -277,6 +289,15 @@ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }:
277
289
  </label>
278
290
  </section>
279
291
 
292
+ <RoomHolds
293
+ times={{ eventId: eventId ?? '', title, start: meta.start, end: meta.end, allDay: meta.allDay, timezone: meta.timezone, rrule: meta.rrule, exdates: meta.exdates }}
294
+ hold={meta.roomHold}
295
+ status={status}
296
+ locationName={meta.location.name}
297
+ onChange={(roomHold) => onChange({ roomHold })}
298
+ onLocation={(name) => onChange({ location: { ...meta.location, name } })}
299
+ />
300
+
280
301
  <section className="pe-sb-section pe-ev">
281
302
  <h3 className="pe-sb-title">Button</h3>
282
303
  <div className="pe-ev-row">
@@ -327,7 +348,7 @@ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }:
327
348
  <section className="pe-sb-section pe-ev">
328
349
  <h3 className="pe-sb-title">Calendars</h3>
329
350
  {calendars.length === 0 ? (
330
- <p className="pe-ev-note">No calendars yet — the event shows on the main calendar. <a href="/admin/calendar/calendars" target="_blank" rel="noopener">Create one</a> to group events.</p>
351
+ <p className="pe-ev-note">No calendars yet — the event shows on the main calendar. <a href="/admin/calendar/settings?new=1" target="_blank" rel="noopener">Create one</a> to group events.</p>
331
352
  ) : (
332
353
  <div className="pe-ev-cals">
333
354
  {calendars.map((c) => {
@@ -31,6 +31,7 @@ import { I18N, localeHomeUrl } from '../../lib/i18n';
31
31
  import { mergeEventDraft, mergePageDraft, mergePostDraft } from '../../lib/drafts';
32
32
  import { CALENDAR, eventUrl, type EventFieldConfig } from '../../lib/calendar/config';
33
33
  import { EventPanel, eventWhenLabel, type CalendarOption, type EventMeta } from './EventPanel';
34
+ import { fetchRoomStatus, NO_HOLD, RoomConflictDialog } from '../calendar/RoomHolds';
34
35
 
35
36
  // The Ghost-style writing surface, shared by posts AND pages: distraction-free
36
37
  // column, cards via "/", autosave, a collapsible settings sidebar, and an
@@ -149,6 +150,7 @@ const NO_EVENT: EventMeta = {
149
150
  start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
150
151
  location: { name: '', address: '', url: '' }, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
151
152
  rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4 },
153
+ roomHold: NO_HOLD,
152
154
  };
153
155
 
154
156
  export default function PostEditor({ kind = 'post', post, page, event, calendars = [], eventFields = [], siteTimezone = '', initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [], site = { title: '', url: '' }, languages = [], mainLanguage, translationDrafts = false }: Props) {
@@ -176,7 +178,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
176
178
  template: event!.template, publishedAt: event!.publishedAt, scheduledAt: event!.scheduledAt,
177
179
  start: event!.start, end: event!.end, allDay: event!.allDay, timezone: event!.timezone, rrule: event!.rrule,
178
180
  exdates: event!.exdates, cancelled: event!.cancelled, location: event!.location, cta: event!.cta,
179
- calendarIds: event!.calendarIds, fields: event!.fields, rsvp: event!.rsvp,
181
+ calendarIds: event!.calendarIds, fields: event!.fields, rsvp: event!.rsvp, roomHold: event!.roomHold,
180
182
  }
181
183
  : {
182
184
  ...NO_EVENT,
@@ -192,7 +194,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
192
194
  const col = kind === 'event'
193
195
  ? { singular: 'Event', plural: 'Events', base: CALENDAR.eventsBase, redirectFrom: [] }
194
196
  : kind === 'page' ? CONTENT.posts : (CONTENT.collections[meta.collection] ?? CONTENT.posts);
195
- const listHref = kind === 'page' ? '/admin/pages' : kind === 'event' ? '/admin/calendar/events' : `/admin/c/${meta.collection}`;
197
+ const listHref = kind === 'page' ? '/admin/pages' : kind === 'event' ? '/admin/calendar' : `/admin/c/${meta.collection}`;
196
198
  const noun = kind === 'page' ? 'page' : col.singular.toLowerCase();
197
199
 
198
200
  const [authors, setAuthors] = useState<AuthorOption[]>([]);
@@ -408,6 +410,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
408
410
  calendarIds: m.calendarIds,
409
411
  fields: m.fields,
410
412
  rsvp: m.rsvp,
413
+ roomHold: m.roomHold,
411
414
  scheduledAt: m.scheduledAt,
412
415
  translations,
413
416
  });
@@ -711,8 +714,17 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
711
714
  toastTimer.current = setTimeout(() => setToast(null), 4500);
712
715
  }
713
716
 
714
- async function transition(patch: Partial<Meta>) {
717
+ // publishing or scheduling an event that holds rooms: check for bookings
718
+ // that overlap first, and let the author keep or cancel each one
719
+ const [roomGate, setRoomGate] = useState<{ patch: Partial<Meta>; rooms: Parameters<typeof RoomConflictDialog>[0]['rooms'] } | null>(null);
720
+ async function transition(patch: Partial<Meta>, roomsChecked = false) {
715
721
  setPublishOpen(false);
722
+ const m0 = metaRef.current;
723
+ if (kind === 'event' && !roomsChecked && (patch.status === 'published' || patch.status === 'scheduled') && m0.roomHold.resourceIds.length) {
724
+ const st = await fetchRoomStatus({ eventId: docId, title: m0.title, start: m0.start, end: m0.end, allDay: m0.allDay, timezone: m0.timezone, rrule: m0.rrule, exdates: m0.exdates }, m0.roomHold).catch(() => null);
725
+ const rooms = (st?.rooms ?? []).filter((r) => m0.roomHold.resourceIds.includes(r.id) && r.conflicts.length);
726
+ if (rooms.length) { setRoomGate({ patch, rooms }); return; }
727
+ }
716
728
  const next = { ...metaRef.current, ...patch };
717
729
  metaRef.current = next;
718
730
  setMetaState(next);
@@ -902,6 +914,19 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
902
914
 
903
915
  return (
904
916
  <div className={`pe-shell ${sidebarOpen ? 'has-sidebar' : ''} ${split ? 'has-split' : ''}`}>
917
+ {roomGate && (
918
+ <RoomConflictDialog
919
+ rooms={roomGate.rooms}
920
+ onCancel={() => setRoomGate(null)}
921
+ onConfirm={async ({ keep, cancel, message }) => {
922
+ if (cancel.length) await actions.events.cancelBookingsForEvent({ ids: cancel, message });
923
+ const hold = metaRef.current.roomHold;
924
+ const patch = { ...roomGate.patch, roomHold: { ...hold, keep: [...new Set([...hold.keep, ...keep])] } };
925
+ setRoomGate(null);
926
+ await transition(patch, true);
927
+ }}
928
+ />
929
+ )}
905
930
  {toast && (
906
931
  <div className="pe-toast" role="status">
907
932
  <span className="pe-toast-check">✓</span>
@@ -911,7 +936,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
911
936
  )}
912
937
  <header className="pe-topbar">
913
938
  <div className="pe-topbar-left">
914
- <a className="pe-btn ghost sm" href={listHref} onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); requestLeave('list'); }} aria-label={kind === 'page' ? 'Back to pages' : `Back to ${col.plural.toLowerCase()}`}>‹<span className="pe-hide-sm">{kind === 'page' ? 'Pages' : col.plural}</span></a>
939
+ <a className="pe-btn ghost sm" href={listHref} onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); requestLeave('list'); }} aria-label={kind === 'page' ? 'Back to pages' : kind === 'event' ? 'Back to the schedule' : `Back to ${col.plural.toLowerCase()}`}>‹<span className="pe-hide-sm">{kind === 'page' ? 'Pages' : kind === 'event' ? 'Schedule' : col.plural}</span></a>
915
940
  <span className={`pe-savestate ${saveState}`}>
916
941
  <i className="pe-save-dot" aria-hidden="true" />
917
942
  {STATUS_LABEL[meta.status]}{pending && <em className="pe-edited-dot"> · edited</em>}<span className="pe-hide-sm"> · {saveLabel}</span>
@@ -1538,11 +1563,14 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
1538
1563
 
1539
1564
  {kind === 'event' && (
1540
1565
  <EventPanel
1566
+ eventId={docId}
1541
1567
  meta={meta}
1542
1568
  onChange={(patch) => setMeta(patch)}
1543
1569
  calendars={calendars}
1544
1570
  fields={eventFields}
1545
1571
  siteTimezone={siteTimezone}
1572
+ title={meta.title}
1573
+ status={meta.status}
1546
1574
  />
1547
1575
  )}
1548
1576
 
@@ -109,15 +109,14 @@ const groups: { id: string; label: string; items: Item[] }[] = [
109
109
  ? [{
110
110
  id: 'calendar',
111
111
  label: 'Calendar',
112
+ // two homes: Schedule (anything on a date — grid, list, calendar
113
+ // settings) and Bookings (anything people reserve — requests, rooms &
114
+ // people, booking links). Sub-pages highlight their home.
112
115
  items: [
113
- { key: 'calendar:events', label: 'Events', href: '/admin/calendar/events', icon: icons.calendar, count: null },
116
+ { key: 'calendar:schedule', label: 'Schedule', href: '/admin/calendar', icon: icons.calendar, count: null },
114
117
  ...(bookingsEnabled()
115
- ? [
116
- { key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 },
117
- { key: 'calendar:resources', label: 'Resources', href: '/admin/calendar/resources', icon: icons.resources, count: null },
118
- ]
118
+ ? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
119
119
  : []),
120
- { key: 'calendar:calendars', label: 'Calendars', href: '/admin/calendar/calendars', icon: icons.calendars, count: null },
121
120
  ],
122
121
  }]
123
122
  : []),
@@ -14,6 +14,7 @@ import { diffDesign } from '../lib/designDiff';
14
14
  import { getTheme } from '../lib/theme';
15
15
  import { analyticsHead, analyticsBody } from '../lib/analytics';
16
16
  import { socialIconPath, socialIconSvg } from '../lib/socialIcons';
17
+ import type { SiteChrome } from '../lib/site-chrome';
17
18
  import '../styles/site.css';
18
19
  import '../styles/cards.css';
19
20
  import '../styles/live.css';
@@ -136,6 +137,30 @@ const readLinks = [
136
137
  ...(SEARCH.enabled ? [{ label: 'Search', url: searchAction }] : []),
137
138
  { label: 'RSS', url: '/rss.xml' },
138
139
  ];
140
+ // a theme's templates/header.astro / footer.astro get this instead of core markup
141
+ const langName = (l: string) => {
142
+ try { const n = new Intl.DisplayNames([l], { type: 'language' }).of(l); return n ? n[0].toLocaleUpperCase(l) + n.slice(1) : l.toUpperCase(); }
143
+ catch { return l.toUpperCase(); }
144
+ };
145
+ const chromeLink = (n: { label: string; url: string }) => ({ label: n.label, url: lnk(n.url), current: isCurrent(lnk(n.url)), external: isExternal(n.url) });
146
+ const chrome: SiteChrome = {
147
+ title: settings.siteTitle, tagline: settings.tagline, logo: settings.siteLogo,
148
+ homeUrl: reqLocale ? localeHomeUrl(reqLocale) : '/',
149
+ links: headerLinks.map(chromeLink),
150
+ footerLinks: footerNav.map(chromeLink),
151
+ social: socialNav.map((n) => ({ ...chromeLink(n), icon: n.icon })),
152
+ readLinks: readLinks.map(chromeLink),
153
+ footerHtml,
154
+ languages: I18N.enabled ? I18N.locales.map((l) => ({ code: l, label: langName(l), url: localePath(barePath, l), current: l === (reqLocale || I18N.defaultLocale) })) : [],
155
+ search: showSearch ? { url: searchAction, label: searchT.label } : null,
156
+ schemeToggle,
157
+ liveHeader: Boolean(liveSignals?.header),
158
+ headerStyle: String(tokens.header), footerStyle: String(tokens.footer),
159
+ };
160
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
+ const HeaderTpl = design.theme.HeaderTemplate as any;
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ const FooterTpl = design.theme.FooterTemplate as any;
139
164
  // hreflang alternates: every public page exists in each language (translated
140
165
  // words where available, the main language otherwise)
141
166
  const isHomePath = I18N.enabled && !noindex && !bare;
@@ -246,7 +271,8 @@ const homeJsonLd =
246
271
  </script>
247
272
  )}
248
273
  {!bare && <a class="skip-link" href="#main">Skip to content</a>}
249
- {!bare && (
274
+ {!bare && HeaderTpl && <HeaderTpl site={chrome} />}
275
+ {!bare && !HeaderTpl && (
250
276
  <header class="site-header" data-style={tokens.header}>
251
277
  <div class="site-header-in">
252
278
  <a class="brand" href={reqLocale ? localeHomeUrl(reqLocale) : '/'}>
@@ -644,7 +670,8 @@ const homeJsonLd =
644
670
  <main class="site-main" id="main">
645
671
  <slot />
646
672
  </main>
647
- {!bare && (
673
+ {!bare && FooterTpl && <FooterTpl site={chrome} />}
674
+ {!bare && !FooterTpl && (
648
675
  <footer class="site-footer" data-style={tokens.footer}>
649
676
  <div class="site-footer-in">
650
677
  {tokens.footer === 'columns' ? (
@@ -2,6 +2,11 @@ import { z } from 'zod';
2
2
  import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking } from '../schema';
3
3
  import type { ContentRepository } from '../repo/types';
4
4
 
5
+ // backups from before booking approval modes: a link that didn't confirm on
6
+ // its own must stay manual (a missing `approval` would follow the site default)
7
+ const legacyApproval = (l: unknown) =>
8
+ l && typeof l === 'object' && !('approval' in l) && (l as { autoConfirm?: boolean }).autoConfirm === false ? { ...l, approval: 'manual' } : l;
9
+
5
10
  export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
6
11
  export type BookingRequest = z.infer<typeof BookingRequest>;
7
12
  export const BackupContent = z.object({
@@ -9,7 +14,7 @@ export const BackupContent = z.object({
9
14
  settings: Settings.partial(), pages: z.array(Page), posts: z.array(Post), media: z.array(Media), links: z.array(Link),
10
15
  tags: z.array(Tag), navItems: z.array(NavItem), users: z.array(User), redirects: z.array(Redirect).default([]),
11
16
  calendars: z.array(Calendar).default([]), events: z.array(CalEvent).default([]), resources: z.array(Resource).default([]),
12
- bookingLinks: z.array(BookingLink).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
17
+ bookingLinks: z.array(z.preprocess(legacyApproval, BookingLink)).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
13
18
  }).superRefine((data, ctx) => {
14
19
  if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
15
20
  const keys = new Set<string>();
@@ -8,6 +8,7 @@ import { ApiError } from '../api';
8
8
  import { bookingsEnabled } from '../calendar/config';
9
9
  import { availabilityContext, bookingJson, BookingError, createResourceBooking, rescheduleBooking, setBookingStatus } from './service';
10
10
  import { addDaysYmd, describeLink, slotsForDay, ymd } from './availability';
11
+ import { confirmsInstantly, linkApproval } from './approval';
11
12
 
12
13
  export const BookingToolInputs = [
13
14
  z.object({ tool: z.literal('list_resources'), arguments: z.object({}).strict() }).strict(),
@@ -65,7 +66,7 @@ export async function runBookingTool(repo: ContentRepository, input: Input, allo
65
66
  try {
66
67
  switch (input.tool) {
67
68
  case 'list_resources':
68
- return { resources: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, capacity: r.capacity, visibility: r.visibility, links: links.filter((l) => l.resourceId === r.id).map((l) => ({ slug: l.slug, name: l.name, rules: describeLink(l), mode: l.mode, lengths: l.mode === 'range' ? l.lengths : [l.duration], autoConfirm: l.autoConfirm, active: l.active, visibility: l.visibility })) })) };
69
+ return { resources: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, capacity: r.capacity, visibility: r.visibility, links: links.filter((l) => l.resourceId === r.id).map((l) => ({ slug: l.slug, name: l.name, rules: describeLink(l), mode: l.mode, lengths: l.mode === 'range' ? l.lengths : [l.duration], autoConfirm: confirmsInstantly(linkApproval(l, settings)), active: l.active, visibility: l.visibility })) })) };
69
70
  case 'check_availability': {
70
71
  const { r, l } = resolve(input.arguments.resource, input.arguments.link);
71
72
  const c = await availabilityContext(repo, r, l, settings);
@@ -74,7 +75,7 @@ export async function runBookingTool(repo: ContentRepository, input: Input, allo
74
75
  const days = Array.from({ length: input.arguments.days }, (_, i) => addDaysYmd(from, i)).map((date) => ({
75
76
  date, slots: slotsForDay(c, date, input.arguments.minutes).slice(0, 60).map((s) => ({ start: new Date(s.start).toISOString(), end: new Date(s.end).toISOString(), minutes: s.minutes })),
76
77
  }));
77
- return { timezone: tz, autoConfirm: l.autoConfirm, days };
78
+ return { timezone: tz, autoConfirm: confirmsInstantly(linkApproval(l, settings)), days };
78
79
  }
79
80
  case 'list_bookings': {
80
81
  const a = input.arguments;
@@ -0,0 +1,70 @@
1
+ // Booking approval: does a new booking confirm at once, or wait for a
2
+ // decision? Each link either follows the site default (Calendar settings →
3
+ // Bookings, seeded from acms({ calendar: { bookings: { approval } } })) or
4
+ // picks its own mode. "rules" confirms unless one of the rules flags the
5
+ // booking. Pure apart from the first-time lookup, which the caller supplies.
6
+ import type { ApprovalMode, ApprovalRules, BookingLink, Settings } from '../schema';
7
+ import { CALENDAR } from '../calendar/config';
8
+
9
+ export interface Approval {
10
+ mode: ApprovalMode;
11
+ rules: ApprovalRules;
12
+ // true when the link follows the site default
13
+ inherited: boolean;
14
+ }
15
+
16
+ export function siteApproval(settings: Pick<Settings, 'calendar'>): { mode: ApprovalMode; rules: ApprovalRules } {
17
+ return { mode: settings.calendar.approval || CALENDAR.bookingApproval, rules: settings.calendar.approvalRules };
18
+ }
19
+
20
+ export function linkApproval(link: Pick<BookingLink, 'approval' | 'approvalRules'>, settings: Pick<Settings, 'calendar'>): Approval {
21
+ if (link.approval === 'default') return { ...siteApproval(settings), inherited: true };
22
+ return { mode: link.approval, rules: link.approvalRules, inherited: false };
23
+ }
24
+
25
+ // what the public page promises before someone books
26
+ export function confirmsInstantly(a: Approval): boolean {
27
+ return a.mode === 'auto';
28
+ }
29
+
30
+ export interface ApprovalInput {
31
+ start: number;
32
+ end: number;
33
+ seats: number;
34
+ // has this email booked (any status but declined/cancelled) before?
35
+ returning: boolean;
36
+ }
37
+
38
+ // why a "rules" booking needs a person to look at it; [] = confirm now
39
+ export function approvalReasons(a: Approval, b: ApprovalInput): string[] {
40
+ if (a.mode === 'auto') return [];
41
+ if (a.mode === 'manual') return ['Every booking on this link needs approval'];
42
+ const r = a.rules;
43
+ const out: string[] = [];
44
+ if (r.longerThan > 0 && (b.end - b.start) / 60_000 > r.longerThan) out.push(`Longer than ${durationLabel(r.longerThan)}`);
45
+ if (r.firstTime && !b.returning) out.push('First booking from this email');
46
+ if (r.moreSeats > 0 && b.seats > r.moreSeats) out.push(`More than ${r.moreSeats} seats`);
47
+ return out;
48
+ }
49
+
50
+ export function decideStatus(a: Approval, b: ApprovalInput): 'confirmed' | 'pending' {
51
+ return approvalReasons(a, b).length ? 'pending' : 'confirmed';
52
+ }
53
+
54
+ export function durationLabel(min: number): string {
55
+ if (min % 1440 === 0) return `${min / 1440} day${min === 1440 ? '' : 's'}`;
56
+ if (min % 60 === 0) return `${min / 60} hour${min === 60 ? '' : 's'}`;
57
+ if (min > 60) return `${Math.floor(min / 60)} h ${min % 60} min`;
58
+ return `${min} min`;
59
+ }
60
+
61
+ export const APPROVAL_LABEL: Record<ApprovalMode, string> = {
62
+ auto: 'Automatically confirm',
63
+ rules: 'Automatically, unless…',
64
+ manual: 'I approve each booking',
65
+ };
66
+
67
+ // one short phrase for rows and badges: "Instant" / "Needs approval" / "Instant, with rules"
68
+ export function approvalShort(a: Approval): string {
69
+ return a.mode === 'auto' ? 'Instant' : a.mode === 'manual' ? 'Needs approval' : 'Instant, with rules';
70
+ }
@@ -0,0 +1,120 @@
1
+ // Rooms held by events. An event that lists rooms/people in roomHold makes
2
+ // them unbookable while it happens (± setup/cleanup) or for the whole day.
3
+ // Holds are computed from events every time — never stored as bookings — so
4
+ // moving, repeating, skipping a date, cancelling or deleting the event frees
5
+ // or moves the room automatically. availabilityContext() adds these to its
6
+ // busy list, so every booking path (pages, embeds, app, API, agent) obeys.
7
+ import type { ContentRepository } from '../repo/types';
8
+ import type { Booking, CalEvent, Resource, Settings } from '../schema';
9
+ import { expandEvents } from '../calendar/events';
10
+ import { tzParts, zonedToUtc } from '../calendar/ics';
11
+ import { resourceZone } from './availability';
12
+
13
+ export interface Hold {
14
+ resourceId: string;
15
+ eventId: string;
16
+ title: string;
17
+ start: number; // incl. setup
18
+ end: number; // incl. cleanup
19
+ eventStart: number; // the occurrence itself
20
+ eventEnd: number;
21
+ }
22
+
23
+ const DAY = 86_400_000;
24
+
25
+ /** Does this event hold its rooms right now? */
26
+ export function holdsRooms(e: Pick<CalEvent, 'status' | 'cancelled' | 'roomHold'>): boolean {
27
+ if (e.cancelled || !e.roomHold.resourceIds.length) return false;
28
+ return e.status === 'published' || e.status === 'scheduled' || (e.status === 'draft' && e.roomHold.whileDraft);
29
+ }
30
+
31
+ // midnight (in tz) of the day containing ms
32
+ function dayStart(ms: number, tz: string): number {
33
+ const p = tzParts(ms, tz);
34
+ return zonedToUtc(p.y, p.m, p.d, 0, 0, 0, tz);
35
+ }
36
+ function nextDayStart(ms: number, tz: string): number {
37
+ // +26h then back to that day's midnight survives DST changes
38
+ return dayStart(dayStart(ms, tz) + DAY + 2 * 3_600_000, tz);
39
+ }
40
+
41
+ /** Holds for the given events in [from, to). `force` counts drafts too (editor previews). */
42
+ export function holdsFromEvents(
43
+ events: CalEvent[],
44
+ resources: Map<string, Pick<Resource, 'id' | 'timezone'>>,
45
+ settings: Pick<Settings, 'timezone'>,
46
+ from: number,
47
+ to: number,
48
+ opts: { force?: boolean; resourceId?: string } = {}
49
+ ): Hold[] {
50
+ const out: Hold[] = [];
51
+ const holding = events.filter((e) => (opts.force ? !e.cancelled && e.roomHold.resourceIds.length > 0 : holdsRooms(e))
52
+ && (!opts.resourceId || e.roomHold.resourceIds.includes(opts.resourceId)));
53
+ if (!holding.length) return out;
54
+ // widen the window so setup/cleanup and whole-day holds at the edges count
55
+ const pad = 2 * DAY;
56
+ const occurrences = expandEvents(holding, settings, { displayTz: settings.timezone || 'UTC', now: from, windowStartMs: from - pad, windowEndMs: to + pad });
57
+ for (const o of occurrences) {
58
+ const h = o.event.roomHold;
59
+ for (const resourceId of h.resourceIds) {
60
+ if (opts.resourceId && resourceId !== opts.resourceId) continue;
61
+ const r = resources.get(resourceId);
62
+ if (!r) continue;
63
+ let start: number;
64
+ let end: number;
65
+ if (h.span === 'day') {
66
+ const tz = resourceZone(r, settings.timezone || 'UTC');
67
+ start = dayStart(o.start, tz);
68
+ end = nextDayStart(Math.max(o.start, o.end - 1), tz);
69
+ } else {
70
+ start = o.start - h.before * 60_000;
71
+ end = o.end + h.after * 60_000;
72
+ }
73
+ if (end <= from || start >= to) continue;
74
+ out.push({ resourceId, eventId: o.event.id, title: o.event.title || 'Event', start, end, eventStart: o.start, eventEnd: o.end });
75
+ }
76
+ }
77
+ return out.sort((a, b) => a.start - b.start);
78
+ }
79
+
80
+ /** Holds on the site's live events (what booking pages must respect). */
81
+ export async function eventHolds(repo: ContentRepository, settings: Pick<Settings, 'timezone'>, from: number, to: number, resourceId?: string): Promise<Hold[]> {
82
+ const [events, resources] = await Promise.all([repo.listEvents(), repo.listResources()]);
83
+ return holdsFromEvents(events, new Map(resources.map((r) => [r.id, r])), settings, from, to, { resourceId });
84
+ }
85
+
86
+ export interface HoldConflict {
87
+ booking: Pick<Booking, 'id' | 'name' | 'email' | 'start' | 'end' | 'status' | 'resourceId' | 'kind' | 'notes'>;
88
+ hold: Hold;
89
+ }
90
+
91
+ /**
92
+ * Bookings that overlap an event's holds. `event` may be unsaved (the
93
+ * editor's current values): holds are computed as if it were published.
94
+ * Repeating events are checked over the next `horizonDays` (default a year).
95
+ */
96
+ export async function holdConflicts(
97
+ repo: ContentRepository,
98
+ settings: Pick<Settings, 'timezone'>,
99
+ event: CalEvent,
100
+ opts: { now?: number; horizonDays?: number } = {}
101
+ ): Promise<{ conflicts: HoldConflict[]; holds: Hold[] }> {
102
+ const now = opts.now ?? Date.now();
103
+ const to = now + (opts.horizonDays ?? 366) * DAY;
104
+ const resources = new Map((await repo.listResources()).map((r) => [r.id, r]));
105
+ const holds = holdsFromEvents([event], resources, settings, now, to, { force: true });
106
+ if (!holds.length) return { conflicts: [], holds };
107
+ const keep = new Set(event.roomHold.keep);
108
+ const conflicts: HoldConflict[] = [];
109
+ for (const resourceId of new Set(holds.map((h) => h.resourceId))) {
110
+ const mine = holds.filter((h) => h.resourceId === resourceId);
111
+ const bookings = await repo.listBookings({ resourceId, from: mine[0].start, to: mine[mine.length - 1].end, statuses: ['pending', 'confirmed'], kinds: ['booking', 'block'] });
112
+ for (const b of bookings) {
113
+ if (keep.has(b.id)) continue;
114
+ // a booking's own buffers (hold_start/end) don't count, only its real time
115
+ const hold = mine.find((h) => b.start < h.end && b.end > h.start);
116
+ if (hold) conflicts.push({ booking: { id: b.id, name: b.name, email: b.email, start: b.start, end: b.end, status: b.status, resourceId: b.resourceId, kind: b.kind, notes: b.notes }, hold });
117
+ }
118
+ }
119
+ return { conflicts: conflicts.sort((a, b) => a.booking.start - b.booking.start), holds };
120
+ }
@@ -12,6 +12,8 @@ import { manageUrl as manageUrlFor, eventUrl } from '../calendar/config';
12
12
  import { eventTimeZone, locationLine } from '../calendar/events';
13
13
  import { bookingEmail, whenText, type BookingEmail } from './emails';
14
14
  import { holdOf, isBookable, resourceZone, type AvailabilityContext, type Interval } from './availability';
15
+ import { decideStatus, linkApproval } from './approval';
16
+ import { eventHolds } from './holds';
15
17
 
16
18
  export interface FlowOptions {
17
19
  origin: string;
@@ -62,11 +64,13 @@ export async function availabilityContext(
62
64
  ignoreBookingId?: string
63
65
  ): Promise<AvailabilityContext> {
64
66
  const to = now + (link.maxAdvance + 2) * 86_400_000;
65
- const [bookings, busy] = await Promise.all([
67
+ const [bookings, busy, held] = await Promise.all([
66
68
  repo.listBookings({ resourceId: resource.id, from: now - 86_400_000, to, statuses: ['pending', 'confirmed'] }),
67
69
  busyIntervals(resource, now - 86_400_000, to, now),
70
+ // events that use this room/person (lib/booking/holds.ts)
71
+ eventHolds(repo, settings, now - 86_400_000, to, resource.id),
68
72
  ]);
69
- return { resource, link, bookings, busy, siteTimezone: settings.timezone, now, ignoreBookingId };
73
+ return { resource, link, bookings, busy: [...busy, ...held.map(({ start, end }) => ({ start, end }))], siteTimezone: settings.timezone, now, ignoreBookingId };
70
74
  }
71
75
 
72
76
  // ---- notifications ---------------------------------------------------------
@@ -171,7 +175,11 @@ export async function createResourceBooking(
171
175
  // staff adding a booking by hand may ignore notice windows, never overlaps
172
176
  const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now());
173
177
  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.');
174
- const status: BookingStatus = args.status ?? (link.autoConfirm ? 'confirmed' : 'pending');
178
+ const approval = linkApproval(link, settings);
179
+ const returning = approval.mode === 'rules' && approval.rules.firstTime
180
+ ? (await repo.listBookings({ email: booker.email, statuses: ['pending', 'confirmed'], kinds: ['booking'] })).length > 0
181
+ : true;
182
+ const status: BookingStatus = args.status ?? decideStatus(approval, { start: args.start, end: args.end, seats: 1, returning });
175
183
  const b = await repo.createBooking({
176
184
  kind: 'booking', resourceId: resource.id, linkId: link.id, eventId: '', status,
177
185
  start: args.start, end: args.end, ...holdOf(link, args.start, args.end),
@@ -275,3 +283,29 @@ export async function sendDueReminders(repo: ContentRepository, opts: FlowOption
275
283
  await kickEmail(repo,opts.locals,opts.waitUntil);
276
284
  return due.length;
277
285
  }
286
+
287
+ // cron: pending bookings nobody answered within the link's deadline get the
288
+ // chosen decision (approve or decline). Bookings that already started are
289
+ // left alone — deciding them now would only send a confusing email.
290
+ export async function decideOverdue(repo: ContentRepository, opts: FlowOptions): Promise<number> {
291
+ const now = opts.now ?? Date.now();
292
+ const pending = await repo.listBookings({ from: now, statuses: ['pending'], kinds: ['booking'] });
293
+ if (!pending.length) return 0;
294
+ const settings = await repo.getSettings();
295
+ const links = new Map<string, BookingLink | null>();
296
+ let decided = 0;
297
+ for (const b of pending) {
298
+ if (b.start <= now) continue;
299
+ if (!links.has(b.linkId)) links.set(b.linkId, await repo.getBookingLink(b.linkId));
300
+ const link = links.get(b.linkId);
301
+ if (!link) continue;
302
+ const { rules } = linkApproval(link, settings);
303
+ if (!rules.deadlineHours || b.createdAt + rules.deadlineHours * 3_600_000 > now) continue;
304
+ try {
305
+ await setBookingStatus(repo, b, rules.deadlineAction === 'decline' ? 'declined' : 'confirmed', opts,
306
+ rules.deadlineAction === 'decline' ? 'We weren’t able to confirm this booking in time. Please pick another time.' : undefined);
307
+ decided++;
308
+ } catch { /* changed meanwhile — next sweep sees the new state */ }
309
+ }
310
+ return decided;
311
+ }
@@ -15,6 +15,14 @@ const eventFields = {
15
15
  location: z.object({ name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(1000).default('') }).strict().optional(),
16
16
  cta: z.object({ label: z.string().max(120).default(''), href: z.string().max(1000).default(''), style: z.enum(['primary', 'outline']).default('primary') }).strict().optional(),
17
17
  calendars: z.array(z.string().max(100)).max(20).optional(),
18
+ // bookable rooms/people (slugs from the booking resources) this event uses:
19
+ // they can't be booked while it happens (± setup/cleanup minutes) or all day
20
+ rooms: z.object({
21
+ resources: z.array(z.string().max(100)).max(50),
22
+ span: z.enum(['event', 'day']).default('event'),
23
+ setup: z.number().int().min(0).max(1440).default(0),
24
+ cleanup: z.number().int().min(0).max(1440).default(0),
25
+ }).strict().optional(),
18
26
  tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
19
27
  cancelled: z.boolean().optional(),
20
28
  seoTitle: z.string().max(300).optional(),
@@ -43,6 +51,6 @@ export const EVENT_TOOL_DESCRIPTIONS: Record<(typeof EVENT_TOOL_NAMES)[number],
43
51
  get_event: 'Read one event including any pending draft. Times are wall-clock strings in the event timezone.',
44
52
  list_calendars: 'List calendars (slug, name, visibility, kind). Use slugs in create_event/update_event "calendars".',
45
53
  create_event: 'Create a DRAFT event. start/end are wall-clock "YYYY-MM-DDTHH:mm" (or "YYYY-MM-DD" with allDay) in the event timezone (site timezone when omitted). content is Markdown. rrule is an RFC 5545 rule like "FREQ=WEEKLY;BYDAY=TU". Never publishes.',
46
- update_event: 'Update supplied event fields. Published/scheduled events stage a pending draft that goes live on publish/Update. calendars replaces the list.',
54
+ update_event: 'Update supplied event fields. rooms (resource slugs) holds bookable rooms so they cannot be booked during the event; [] releases them. Published/scheduled events stage a pending draft that goes live on publish/Update. calendars replaces the list.',
47
55
  publish_event: 'Publish an event (applying any pending draft). Requires owner-enabled publishing permission.',
48
56
  };
@@ -35,6 +35,17 @@ async function resolveCalendars(repo: ContentRepository, slugs: string[] | undef
35
35
  });
36
36
  }
37
37
 
38
+ async function resolveRooms(repo: ContentRepository, rooms: { resources: string[]; span: 'event' | 'day'; setup: number; cleanup: number } | undefined, keep: string[] = []) {
39
+ if (rooms === undefined) return undefined;
40
+ const all = await repo.listResources();
41
+ const resourceIds = rooms.resources.map((s) => {
42
+ const r = all.find((x) => x.slug === s || x.id === s);
43
+ if (!r) throw new ApiError(400, `Unknown room or person "${s}". Call list_resources for valid slugs.`);
44
+ return r.id;
45
+ });
46
+ return { resourceIds, span: rooms.span, before: rooms.setup, after: rooms.cleanup, whileDraft: false, keep };
47
+ }
48
+
38
49
  function checkLinks(a: { cta?: { href: string }; location?: { url: string }; timezone?: string }) {
39
50
  if (a.cta?.href && !/^(https?:\/\/|\/(?!\/)|mailto:|tel:)/i.test(a.cta.href)) throw new ApiError(400, 'cta.href must start with https://, /, mailto: or tel:.');
40
51
  if (a.location?.url && !/^https?:\/\//i.test(a.location.url)) throw new ApiError(400, 'location.url must start with https://.');
@@ -54,6 +65,7 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
54
65
  start: e.start, end: e.end, allDay: e.allDay, rrule: e.rrule,
55
66
  next: o ? formatOccurrence(o, settings.timezone || 'UTC', siteLocale(settings)).range : '',
56
67
  calendars: e.calendarIds.map((id) => calMap.get(id)?.slug).filter(Boolean),
68
+ rooms: e.roomHold.resourceIds.length ? { resources: e.roomHold.resourceIds, span: e.roomHold.span, setup: e.roomHold.before, cleanup: e.roomHold.after } : null,
57
69
  hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id),
58
70
  };
59
71
  };
@@ -91,13 +103,14 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
91
103
  rrule: a.rrule ?? '', excerpt: a.excerpt ?? '', location: a.location, cta: a.cta, tags: a.tags,
92
104
  cancelled: a.cancelled ?? false, seoTitle: a.seoTitle, seoDesc: a.seoDesc,
93
105
  calendarIds: (await resolveCalendars(repo, a.calendars)) ?? [],
106
+ ...(a.rooms ? { roomHold: await resolveRooms(repo, a.rooms) } : {}),
94
107
  ...(a.content !== undefined ? { bodyHtml: renderAgentMarkdown(a.content) } : {}),
95
108
  });
96
109
  if (a.tags?.length) await repo.ensureTags(a.tags);
97
110
  return { ...summary(e) };
98
111
  }
99
112
  case 'update_event': {
100
- const { id, content, calendars: slugs, ...rest } = input.arguments;
113
+ const { id, content, calendars: slugs, rooms, ...rest } = input.arguments;
101
114
  checkLinks(rest);
102
115
  const before = await repo.getEvent(id);
103
116
  if (!before) throw new ApiError(404, 'Event not found.');
@@ -108,6 +121,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
108
121
  }
109
122
  const calendarIds = await resolveCalendars(repo, slugs);
110
123
  if (calendarIds) data.calendarIds = calendarIds;
124
+ const roomHold = await resolveRooms(repo, rooms, before.roomHold.keep);
125
+ if (roomHold) data.roomHold = roomHold;
111
126
  if (data.start !== undefined || data.end !== undefined || data.allDay !== undefined) {
112
127
  const merged = mergeEventDraft(before);
113
128
  try {