@acms-subir/core 0.3.39 → 0.3.41

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 (55) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/cli/migrations.generated.mjs +5 -0
  3. package/docs/mobile.md +2 -1
  4. package/integration.mjs +3 -0
  5. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
  6. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
  7. package/mobile-template/recipes/rsvp/recipe.json +2 -2
  8. package/package.json +1 -1
  9. package/src/actions/index.ts +1 -1
  10. package/src/components/EventPage.astro +16 -8
  11. package/src/components/editor/EventPanel.tsx +3 -2
  12. package/src/components/editor/PostEditor.tsx +1 -1
  13. package/src/components/editor/event/RegistrationCard.tsx +9 -0
  14. package/src/components/editor/event/registration.ts +1 -1
  15. package/src/lib/booking/agent.ts +1 -1
  16. package/src/lib/booking/emails.ts +54 -15
  17. package/src/lib/booking/service.ts +207 -23
  18. package/src/lib/calendar/api.ts +3 -2
  19. package/src/lib/calendar/members-config.ts +5 -0
  20. package/src/lib/calendar/requests.ts +4 -4
  21. package/src/lib/calendar/rsvp-words.ts +69 -8
  22. package/src/lib/content-service.ts +13 -5
  23. package/src/lib/drafts.ts +2 -2
  24. package/src/lib/editor/bookings.ts +5 -3
  25. package/src/lib/editor/documents.ts +4 -2
  26. package/src/lib/editor/schemas.ts +1 -1
  27. package/src/lib/email/booking-words.ts +82 -2
  28. package/src/lib/email/service.ts +1 -1
  29. package/src/lib/email/store.ts +17 -1
  30. package/src/lib/email/types.ts +3 -1
  31. package/src/lib/forms/events.ts +4 -2
  32. package/src/lib/legal/app-privacy.ts +2 -2
  33. package/src/lib/legal/text-en.ts +1 -1
  34. package/src/lib/legal/text-es.ts +1 -1
  35. package/src/lib/mobile/bookings.ts +4 -3
  36. package/src/lib/mobile/calendar.ts +23 -1
  37. package/src/lib/mobile/manage.ts +18 -4
  38. package/src/lib/mobile/openapi.ts +6 -3
  39. package/src/lib/mobile/rsvp.ts +10 -5
  40. package/src/lib/mobile/schemas.ts +12 -4
  41. package/src/lib/repo/migrations.ts +5 -0
  42. package/src/lib/repo/sql.ts +54 -13
  43. package/src/lib/repo/types.ts +15 -5
  44. package/src/lib/scheduler.ts +3 -1
  45. package/src/lib/schema.ts +13 -2
  46. package/src/pages/admin/calendar/bookings.astro +57 -13
  47. package/src/pages/api/calendar/bookings/index.ts +1 -1
  48. package/src/pages/api/calendar/docs.ts +2 -2
  49. package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
  50. package/src/pages/api/v1/events/[slug]/index.ts +8 -0
  51. package/src/pages/book/manage/[token].astro +57 -15
  52. package/src/pages/events/[slug].astro +3 -2
  53. package/src/styles/admin.css +3 -1
  54. package/src/styles/site.css +10 -1
  55. package/src/theme-sdk/index.ts +1 -1
@@ -20,8 +20,9 @@ export interface EventMeta {
20
20
  cta: EventCta;
21
21
  calendarIds: string[];
22
22
  fields: Record<string, string>;
23
- // closesAt: wall time like start; closeBefore: minutes before each date (0 = until it ends)
24
- rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; form?: FormDefinition };
23
+ // closesAt: wall time like start; closeBefore: minutes before each date (0 = until it ends);
24
+ // notifyStaff: the hosts get an email about each new or cancelled RSVP; waitlist: a full date takes places in line
25
+ rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; waitlist?: boolean; form?: FormDefinition };
25
26
  roomHold: RoomHoldValue;
26
27
  }
27
28
 
@@ -169,7 +169,7 @@ const NO_MAPS: MapsStatus = { enabled: false, provider: '', label: '', search: f
169
169
  const NO_EVENT: EventMeta = {
170
170
  start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
171
171
  location: NO_LOCATION, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
172
- rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0 },
172
+ rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true, waitlist: false },
173
173
  roomHold: NO_HOLD,
174
174
  };
175
175
 
@@ -73,6 +73,15 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
73
73
  <span><b>Confirm automatically</b><small>Off: RSVPs wait for your approval in Bookings</small></span>
74
74
  <input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.autoConfirm} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, autoConfirm: e.target.checked } })} />
75
75
  </label>
76
+ <label className="pe-switch-row">
77
+ <span><b>Email the hosts</b><small>About each new RSVP and cancellation. Without hosts, the contact email in Settings → Legal gets them</small></span>
78
+ <input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.notifyStaff !== false} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, notifyStaff: e.target.checked } })} />
79
+ </label>
80
+ {/* a waitlist needs a limit to be full */}
81
+ <label className="pe-switch-row">
82
+ <span><b>Offer a waitlist when it’s full</b><small>{meta.rsvp.capacity ? 'People join a line. When a seat opens, the next person is emailed and it’s held for them for 24 hours' : 'Set a number of seats to use it'}</small></span>
83
+ <input type="checkbox" role="switch" className="pe-switch" disabled={!meta.rsvp.capacity} checked={Boolean(meta.rsvp.waitlist && meta.rsvp.capacity)} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, waitlist: e.target.checked } })} />
84
+ </label>
76
85
  </div>
77
86
  <Deadline meta={meta} onChange={onChange} />
78
87
  <div className="pe-evr-form">
@@ -9,7 +9,7 @@ export type RegistrationMode = 'none' | 'link' | 'rsvp';
9
9
 
10
10
  interface RegistrationData {
11
11
  cta: EventCta;
12
- rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number };
12
+ rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; waitlist?: boolean };
13
13
  }
14
14
 
15
15
  export function registrationMode({ cta, rsvp }: RegistrationData): RegistrationMode {
@@ -18,7 +18,7 @@ export const BookingToolInputs = [
18
18
  minutes: z.number().int().optional(),
19
19
  }).strict() }).strict(),
20
20
  z.object({ tool: z.literal('list_bookings'), arguments: z.object({
21
- status: z.enum(['pending', 'confirmed', 'declined', 'cancelled']).optional(), resource: z.string().optional(),
21
+ status: z.enum(['pending', 'confirmed', 'declined', 'cancelled', 'waitlisted', 'offered', 'expired']).optional(), resource: z.string().optional(),
22
22
  from: z.string().optional(), limit: z.number().int().min(1).max(100).default(25),
23
23
  }).strict() }).strict(),
24
24
  z.object({ tool: z.literal('create_booking'), arguments: z.object({
@@ -13,6 +13,7 @@ import { isExtraLocale } from '../translations';
13
13
  import { fill } from '../live/words';
14
14
  import { bookingStrings, mailCopy, type BookingStrings } from '../email/booking-words';
15
15
  import type { Mail } from '../email';
16
+ import type { RsvpStaffNotice } from '../email/types';
16
17
 
17
18
  const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
18
19
 
@@ -43,6 +44,11 @@ export function whenText(b: Pick<Booking, 'start' | 'end'>, tz: string, settings
43
44
  return `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} · ${f({ hour: 'numeric', minute: '2-digit' }).format(b.start)} – ${f({ hour: 'numeric', minute: '2-digit' }).format(b.end)} ${zone}`.trim();
44
45
  }
45
46
 
47
+ /** until when a freed spot is held, in the booking's language and the event's zone: "Fri, Nov 7, 6:00 PM UTC" */
48
+ export function untilText(at: number, tz: string, settings: Settings): string {
49
+ return new Intl.DateTimeFormat(siteLocale(settings), { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short', timeZone: tz }).format(at);
50
+ }
51
+
46
52
  const utc = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
47
53
 
48
54
  // lang: the description's language ("Manage: <url>")
@@ -75,30 +81,41 @@ interface Ctx {
75
81
  allDay?: boolean;
76
82
  /** the words' language: the booking's for guest emails, the main one for staff */
77
83
  lang?: string;
84
+ /** staff RSVP emails: the date's short form, seats taken right after, why they get it and where the person did it */
85
+ rsvpStaff?: { date: string; taken: number; capacity: number; why: RsvpStaffNotice['why']; via: Booking['source'] };
86
+ /** manageUrl is the recipient's My events page (a host without Manage bookings) */
87
+ mineLink?: boolean;
88
+ /** waitlist emails: the place in line (null once it isn't in line), how many hours a freed spot is held, and until when this one is */
89
+ waitlist?: { position: number | null; hours: number; until: string };
78
90
  }
79
91
 
80
- function layout(heading: string, intro: string, c: Ctx, w: BookingStrings, button?: { href: string; label: string }): { html: string; text: string } {
81
- const rows: [string, string][] = [[w.what, c.title], [w.when, c.when], ...(c.location ? [[w.where, c.location] as [string, string]] : [])];
82
- if (c.booking.seats > 1) rows.push([w.seats, String(c.booking.seats)]);
83
- const reference = fill(w.reference, { ref: c.booking.id.slice(0, 8).toUpperCase() });
92
+ // own: a staff email's own rows and footer instead of the details and reference
93
+ function layout(heading: string, intro: string, c: Ctx, w: BookingStrings, button?: { href: string; label: string }, own?: { rows: [string, string][]; footer: string }): { html: string; text: string } {
94
+ const rows: [string, string][] = own?.rows ?? [[w.what, c.title], [w.when, c.when], ...(c.location ? [[w.where, c.location] as [string, string]] : [])];
95
+ if (!own && c.booking.seats > 1) rows.push([w.seats, String(c.booking.seats)]);
96
+ const footer = own?.footer ?? fill(w.reference, { ref: c.booking.id.slice(0, 8).toUpperCase() });
84
97
  const html = `<!doctype html><html lang="${esc(c.lang || 'en')}"><body style="margin:0;background:#f6f5f2;font-family:-apple-system,Segoe UI,Inter,Arial,sans-serif;color:#1e1f24">
85
98
  <div style="max-width:520px;margin:0 auto;padding:28px 20px">
86
99
  <p style="font-size:13px;color:#6b6e78;margin:0 0 18px">${esc(c.siteTitle)}</p>
87
- <h1 style="font-size:22px;margin:0 0 8px">${esc(heading)}</h1>
88
- <p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>
100
+ <h1 style="font-size:22px;margin:0 0 ${intro ? 8 : 18}px">${esc(heading)}</h1>
101
+ ${intro ? `<p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>` : ''}
89
102
  <table style="width:100%;border-collapse:collapse;background:#fff;border:1px solid #e5e3de;border-radius:10px">${rows
90
- .map(([k, v]) => `<tr><td style="padding:10px 14px;font-size:13px;color:#6b6e78;width:70px;border-bottom:1px solid #eeece8">${esc(k)}</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #eeece8">${esc(v)}</td></tr>`)
103
+ .map(([k, v]) => `<tr><td style="padding:10px 14px;font-size:13px;color:#6b6e78;width:70px;border-bottom:1px solid #eeece8">${esc(k)}</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #eeece8">${esc(v).replace(/\n/g, '<br>')}</td></tr>`)
91
104
  .join('')}</table>
92
105
  ${c.message ? `<p style="font-size:14px;line-height:1.55;margin:18px 0 0">${esc(c.message)}</p>` : ''}
93
106
  ${button ? `<p style="margin:22px 0 0"><a href="${esc(button.href)}" style="display:inline-block;background:#1e1f24;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:10px 16px;border-radius:8px">${esc(button.label)}</a></p>` : ''}
94
- <p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(reference)}</p>
107
+ <p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(footer)}</p>
95
108
  </div></body></html>`;
96
- const text = [heading, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '', reference].join('\n');
109
+ const text = [heading, '', ...(intro ? [intro, ''] : []), ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '', footer].join('\n');
97
110
  return { html, text };
98
111
  }
99
112
 
100
- // link: the booking's own link again, after someone tried to make it twice
101
- export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'link' | 'staff-new';
113
+ // link: the booking's own link again, after someone tried to make it twice.
114
+ // waitlisted / offered / left: they joined a full date's waitlist, a spot
115
+ // opened and is held for them, or they're off the waitlist (left it, released
116
+ // the spot or staff removed them). staff-rsvp(-cancelled): the hosts hear
117
+ // about a new RSVP, or one the person cancelled
118
+ export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'link' | 'waitlisted' | 'offered' | 'left' | 'staff-new' | 'staff-rsvp' | 'staff-rsvp-cancelled';
102
119
 
103
120
  export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
104
121
  const w = bookingStrings(c.lang);
@@ -106,10 +123,11 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
106
123
  const manage = { href: c.manageUrl, label: rsvp ? w.rsvp.manage : w.manage };
107
124
  const ics = (method: 'REQUEST' | 'CANCEL' = 'REQUEST') => [{ filename: 'booking.ics', type: `text/calendar; charset=utf-8; method=${method === 'CANCEL' ? 'CANCEL' : c.organizer ? 'REQUEST' : 'PUBLISH'}`, content: bookingIcs(c.booking, c.title, c.location, c.manageUrl, c.host, method, { organizer: c.organizer, allDay: c.allDay, lang: c.lang }) }];
108
125
  // null: no button (nothing left to change)
109
- const guest = (k: Exclude<BookingEmail, 'staff-new'>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
126
+ const guest = (k: Exclude<BookingEmail, `staff-${string}`>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
110
127
  const copy = mailCopy(w, k, rsvp);
111
- const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when };
112
- return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(copy.heading, fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
128
+ const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when,
129
+ position: String(c.waitlist?.position ?? ''), hours: String(c.waitlist?.hours ?? ''), until: c.waitlist?.until ?? '' };
130
+ return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(fill(copy.heading, vars), fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
113
131
  };
114
132
  switch (kind) {
115
133
  case 'received': return guest('received');
@@ -119,7 +137,11 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
119
137
  case 'updated': return { ...guest('updated'), attachments: ics() };
120
138
  case 'rescheduled': return { ...guest('rescheduled'), attachments: ics() };
121
139
  case 'reminder': return guest('reminder');
122
- case 'link': return guest('link');
140
+ // asked again while in line or holding a spot: that email again
141
+ case 'link': return c.booking.status === 'waitlisted' || c.booking.status === 'offered' ? bookingEmail(c.booking.status, c) : guest('link');
142
+ case 'waitlisted': return guest('waitlisted', {}, { href: c.manageUrl, label: w.waitlist.manage });
143
+ case 'offered': return guest('offered', {}, { href: c.manageUrl, label: w.waitlist.confirm });
144
+ case 'left': return guest('left', {}, null);
123
145
  case 'staff-new': {
124
146
  const pending = c.booking.status === 'pending';
125
147
  const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
@@ -128,5 +150,22 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
128
150
  const m = layout(pending ? w.staff.pending : w.staff.booked, `${who}${answers ? `\n${answers}` : ''}`, c, w, { href: c.manageUrl, label: pending ? w.staff.review : w.staff.open });
129
151
  return { to: [], subject: fill(pending ? w.staff.pendingSubject : w.staff.bookedSubject, vars), ...m, replyTo: c.booking.email || undefined };
130
152
  }
153
+ case 'staff-rsvp':
154
+ case 'staff-rsvp-cancelled': {
155
+ const r = w.rsvpStaff, s = c.rsvpStaff!, b = c.booking;
156
+ const cancelled = kind === 'staff-rsvp-cancelled', pending = !cancelled && b.status === 'pending';
157
+ const seats = String(b.seats), count = String(s.taken);
158
+ const answers = Object.entries(b.answers).map(([k, v]) => `${k}: ${v}`).join('\n');
159
+ const rows: [string, string][] = [
160
+ [r.name, b.name], [r.email, b.email], ...(b.phone ? [[r.phone, b.phone] as [string, string]] : []), [w.when, c.when],
161
+ [w.seats, fill(cancelled ? (b.seats === 1 ? r.seatFreed : r.seatsFreed) : pending ? r.pending : r.confirmed, { seats })],
162
+ ...(answers ? [[r.answers, answers] as [string, string]] : []),
163
+ [r.now, s.capacity ? fill(r.seatsTakenOf, { count, capacity: String(s.capacity) }) : fill(s.taken === 1 ? r.seatTaken : r.seatsTaken, { count })],
164
+ ];
165
+ const vars = { title: c.title, date: s.date };
166
+ const footer = `${r.via[s.via === 'web' || s.via === 'app' ? s.via : 'staff']} · ${s.why === 'hosts' ? r.hosts : r.noHosts}`;
167
+ const m = layout(fill(cancelled ? r.cancelledHeading : r.heading, vars), '', c, w, { href: c.manageUrl, label: c.mineLink ? r.openMine : pending ? w.staff.review : w.staff.open }, { rows, footer });
168
+ return { to: [], subject: fill(cancelled ? r.cancelledSubject : r.subject, vars), ...m, replyTo: b.email || undefined };
169
+ }
131
170
  }
132
171
  }
@@ -9,15 +9,21 @@ import { loadFeed, expandVEvents } from '../calendar/ics';
9
9
  import { emitWebhook } from '../webhooks';
10
10
  import { parseFrom } from '../email/config';
11
11
  import { kickEmail } from '../email/service';
12
+ import type { RsvpStaffNotice } from '../email/types';
13
+ import { LEGAL } from '../legal/config';
14
+ import { isRole } from '../roles';
15
+ import { siteLocale } from '../dates';
12
16
  import { eventUrl } from '../calendar/config';
13
17
  import { eventTimeZone, locationLine, occurrenceKey, rsvpDeadline } from '../calendar/events';
14
18
  import { normalizePageLocale } from '../i18n';
15
19
  import { localizeDoc } from '../translations';
16
20
  import { bookingStrings } from '../email/booking-words';
17
- import { bookingEmail, bookingLanguage, bookingManagePath, whenText, type BookingEmail } from './emails';
21
+ import { bookingEmail, bookingLanguage, bookingManagePath, untilText, whenText, type BookingEmail } from './emails';
18
22
  import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
19
23
  import { decideStatus, linkApproval } from './approval';
20
24
  import { eventHolds } from './holds';
25
+ import { MEMBERS, memberEventsPath } from '../calendar/members-config';
26
+ import { principalCan } from '../permissions/access';
21
27
 
22
28
  export interface FlowOptions {
23
29
  origin: string;
@@ -37,6 +43,9 @@ const token = () => {
37
43
  return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
38
44
  };
39
45
 
46
+ /** how long a spot freed for the next person on a waitlist is held for them (ms) */
47
+ export const WAITLIST_HOLD = 24 * 3_600_000;
48
+
40
49
  // ---- availability context ------------------------------------------------
41
50
 
42
51
  const busyCache = new Map<string, { at: number; intervals: Interval[] }>();
@@ -88,7 +97,7 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
88
97
  const ev = raw && locale ? localizeDoc(raw, locale) : raw;
89
98
  const tz = ev ? eventTimeZone(ev, settings) : settings.timezone || 'UTC';
90
99
  return {
91
- resource: null, link: null, event: ev,
100
+ resource: null, link: null, event: ev, tz,
92
101
  title: ev?.title ?? 'Event',
93
102
  location: ev ? locationLine(ev.location) : '',
94
103
  when: whenText(b, tz, settings, ev?.allDay),
@@ -97,7 +106,7 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
97
106
  const [resource, link] = await Promise.all([repo.getResource(b.resourceId), b.linkId ? repo.getBookingLink(b.linkId) : Promise.resolve(null)]);
98
107
  const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
99
108
  return {
100
- resource, link, event: null as CalEvent | null,
109
+ resource, link, event: null as CalEvent | null, tz,
101
110
  title: [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
102
111
  location: resource?.name ?? '',
103
112
  when: whenText(b, tz, settings, link?.mode === 'day'),
@@ -114,11 +123,17 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
114
123
  };
115
124
  }
116
125
 
117
- // snapshot.locale: the language to use instead of the booking's (a link sent again)
118
- export async function bookingMail(repo: ContentRepository, b: Booking, kind: BookingEmail, origin: string, from = '', snapshot?: { message?: string; eventTitle?: string; eventLocation?: string; eventAllDay?: number; locale?: string }) {
126
+ // the booking in the admin; an RSVP among its event's (a cancelled one under Cancelled)
127
+ const adminBookingUrl = (origin: string, b: Booking, kind: BookingEmail) =>
128
+ `${origin}/admin/calendar/bookings?${b.eventId ? `event=${encodeURIComponent(b.eventId)}&` : ''}${kind === 'staff-rsvp-cancelled' ? 'tab=cancelled&' : ''}b=${b.id}`;
129
+
130
+ // snapshot.locale: the language to use instead of the booking's (a link sent
131
+ // again); taken/why/via: a staff RSVP email's job (rsvpStaffJobs)
132
+ export async function bookingMail(repo: ContentRepository, b: Booking, kind: BookingEmail, origin: string, from = '', snapshot?: { message?: string; eventTitle?: string; eventLocation?: string; eventAllDay?: number; locale?: string; taken?: number; why?: RsvpStaffNotice['why']; via?: Booking['source'] }, recipient = '') {
119
133
  const site = await repo.getSettings();
134
+ const staff = kind.startsWith('staff-');
120
135
  // the guest hears in the language they booked in; staff in the main one
121
- const language = bookingLanguage(kind === 'staff-new' ? { locale: '' } : typeof snapshot?.locale === 'string' ? { locale: snapshot.locale } : b, site);
136
+ const language = bookingLanguage(staff ? { locale: '' } : typeof snapshot?.locale === 'string' ? { locale: snapshot.locale } : b, site);
122
137
  const settings = language.settings;
123
138
  const d = await describe(repo,b,settings,language.locale);
124
139
  if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
@@ -126,10 +141,47 @@ export async function bookingMail(repo: ContentRepository, b: Booking, kind: Boo
126
141
  try { d.location = locationLine(JSON.parse(snapshot.eventLocation || '{}')); } catch { d.location = ''; }
127
142
  d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
128
143
  }
144
+ // "Fri Nov 7": the subject's date, in the event's zone
145
+ const counts = kind === 'staff-rsvp' || kind === 'staff-rsvp-cancelled' ? {
146
+ date: new Intl.DateTimeFormat(siteLocale(settings), { weekday: 'short', month: 'short', day: 'numeric', timeZone: d.tz }).format(b.start).replace(/,/g, ''),
147
+ taken: snapshot?.taken ?? await seatsTaken(repo, b.eventId, b.start), capacity: d.event?.rsvp.capacity ?? 0,
148
+ why: snapshot?.why ?? 'hosts', via: snapshot?.via ?? b.source,
149
+ } : undefined;
150
+ // a host without Manage bookings (a member who hosts) gets their My events
151
+ // page for the event instead of an admin page they can't open
152
+ const mine = counts && d.event && recipient ? await memberHostPath(repo, d.event, recipient) : '';
153
+ // a place in line (its number when sent) or a held spot (until when, in the event's zone)
154
+ const waitlist = b.status === 'waitlisted' || b.status === 'offered' ? {
155
+ position: await waitlistPosition(repo, b), hours: WAITLIST_HOLD / 3_600_000,
156
+ until: b.holdUntil === null ? '' : untilText(b.holdUntil, d.tz, settings),
157
+ } : undefined;
129
158
  return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
130
- manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
159
+ manageUrl:mine ? origin + mine : staff ? adminBookingUrl(origin, b, kind) : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
160
+ mineLink: !!mine,
131
161
  siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
132
- allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang });
162
+ allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang, rsvpStaff: counts, waitlist });
163
+ }
164
+
165
+ /** The member's My events page for an event they host, when they can't manage bookings in the admin ('' otherwise). */
166
+ async function memberHostPath(repo: ContentRepository, e: CalEvent, email: string): Promise<string> {
167
+ const user = (await repo.listUsers()).find((u) => u.email && u.email.trim().toLowerCase() === email.trim().toLowerCase());
168
+ if (!user || !MEMBERS.enabled || !e.hostIds.includes(user.id)) return '';
169
+ return (await principalCan(repo, { user }, 'schedule.bookings')) ? '' : `${memberEventsPath()}/${encodeURIComponent(e.id)}`;
170
+ }
171
+
172
+ /**
173
+ * Who hears about an event's RSVPs (rsvp.notifyStaff): its hosts, else the
174
+ * contact email in Settings → Legal (then acms({ legal }), as legalOperator
175
+ * reads it), else the owners. Accounts that were removed, or whose role the
176
+ * site no longer has, are skipped. via: where the person did it.
177
+ */
178
+ export async function rsvpStaff(repo: ContentRepository, e: CalEvent, settings: Settings, via: Booking['source']): Promise<RsvpStaffNotice | undefined> {
179
+ if (!e.rsvp.notifyStaff) return undefined;
180
+ const contact = settings.legal.contactEmail || LEGAL.contactEmail;
181
+ const users = e.hostIds.length || !contact ? (await repo.listUsers()).filter((u) => u.email && isRole(u.role)) : [];
182
+ const hosts = users.filter((u) => e.hostIds.includes(u.id)).map((u) => u.email);
183
+ const to = hosts.length ? hosts : contact ? [contact] : users.filter((u) => u.role === 'owner').map((u) => u.email);
184
+ return { to: [...new Set(to.map((a) => a.trim().toLowerCase()))].filter(Boolean), why: hosts.length ? 'hosts' : 'contact', via };
133
185
  }
134
186
  async function notify(repo: ContentRepository, _b: Booking, _kinds: BookingEmail[], opts: FlowOptions, _message?: string) {
135
187
  // Repository mutations already committed their outbox jobs atomically.
@@ -195,22 +247,29 @@ export function guestChanges(b: Booking, link: BookingLink | null, now = Date.no
195
247
  const active = b.status === 'pending' || b.status === 'confirmed';
196
248
  const cutoff = b.kind === 'booking' ? (link?.changeCutoff ?? 0) : 0;
197
249
  const closed = active && cutoff > 0 && b.start - now < cutoff * 3_600_000;
198
- const open = active && b.end > now && !closed;
250
+ // a place in line or a held spot can be left until the date ends
251
+ const open = (active || b.status === 'waitlisted' || b.status === 'offered') && b.end > now && !closed;
199
252
  return {
200
253
  cancel: open,
201
- reschedule: open && b.kind === 'booking' && b.start > now && !!link?.active,
254
+ reschedule: open && active && b.kind === 'booking' && b.start > now && !!link?.active,
255
+ // a spot offered from the waitlist, still held for them
256
+ confirm: b.status === 'offered' && (b.holdUntil ?? 0) > now && b.end > now,
202
257
  // hours before the start when guest changes close; 0 = until it ends
203
258
  cutoffHours: cutoff,
204
259
  closed,
205
260
  };
206
261
  }
207
262
 
208
- export async function guestCancel(repo: ContentRepository, b: Booking, opts: FlowOptions): Promise<Booking> {
263
+ // via: where they cancelled (the manage page, or an app)
264
+ export async function guestCancel(repo: ContentRepository, b: Booking, opts: FlowOptions, via: Booking['source'] = 'web'): Promise<Booking> {
209
265
  const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
210
266
  if (b.status === 'cancelled') return b;
211
267
  const can = guestChanges(b, link, opts.now);
212
268
  if (!can.cancel) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to cancel.` : 'This booking can’t be cancelled.');
213
- return setBookingStatus(repo, b, 'cancelled', opts);
269
+ // the person cancelled an RSVP: its hosts hear about it (staff cancelling never emails staff;
270
+ // leaving the waitlist or a held spot isn't an RSVP to them)
271
+ const event = b.kind === 'rsvp' && b.eventId && (b.status === 'pending' || b.status === 'confirmed') ? await repo.getEvent(b.eventId) : null;
272
+ return setBookingStatus(repo, b, 'cancelled', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
214
273
  }
215
274
 
216
275
  /** the link as a guest moving a booking sees it: no new time inside the change cutoff */
@@ -266,14 +325,34 @@ export async function createBlock(repo: ContentRepository, resource: Resource, s
266
325
  });
267
326
  }
268
327
 
328
+ // pending, confirmed and held (offered) RSVPs take seats, as in the capacity
329
+ // check; a place in line doesn't
330
+ const seated = (s: BookingStatus) => s === 'pending' || s === 'confirmed' || s === 'offered';
269
331
  export async function seatsTaken(repo: ContentRepository, eventId: string, start: number): Promise<number> {
270
- const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['pending', 'confirmed'], kinds: ['rsvp'] });
332
+ const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['pending', 'confirmed', 'offered'], kinds: ['rsvp'] });
271
333
  return list.filter((b) => b.start === start).reduce((n, b) => n + b.seats, 0);
272
334
  }
273
335
 
336
+ /** whether a full date takes places in line: the event keeps a waitlist and has a capacity */
337
+ export const waitlistOn = (e: CalEvent) => e.rsvp.enabled && e.rsvp.waitlist && e.rsvp.capacity > 0;
338
+
339
+ /** a date's waitlist in order: who joined first, then by id */
340
+ export async function waitlistLine(repo: ContentRepository, eventId: string, start: number): Promise<Booking[]> {
341
+ const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['waitlisted'], kinds: ['rsvp'] });
342
+ return list.filter((b) => b.start === start).sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
343
+ }
344
+
345
+ /** a waitlisted RSVP's place in its date's line (1 = next), else null */
346
+ export async function waitlistPosition(repo: ContentRepository, b: Booking): Promise<number | null> {
347
+ if (b.status !== 'waitlisted') return null;
348
+ const i = (await waitlistLine(repo, b.eventId, b.start)).findIndex((x) => x.id === b.id);
349
+ return i < 0 ? null : i + 1;
350
+ }
351
+
274
352
  export async function createRsvp(
275
353
  repo: ContentRepository,
276
- args: { event: CalEvent; start: number; end: number; seats: number; booker: BookerInput; source?: Booking['source'] },
354
+ // waitlist: a full date (fewer seats left than asked) takes a place in line instead, when the event keeps one
355
+ args: { event: CalEvent; start: number; end: number; seats: number; booker: BookerInput; source?: Booking['source']; waitlist?: boolean },
277
356
  opts: FlowOptions
278
357
  ): Promise<Booking> {
279
358
  const e = args.event;
@@ -294,15 +373,22 @@ export async function createRsvp(
294
373
  if (formResponse) booker.answers = displayAnswers(formResponse);
295
374
  // one RSVP per address and date on public channels; staff may add more
296
375
  const once = args.source !== 'admin';
297
- const b = await repo.createBooking({
298
- kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
376
+ const source = args.source ?? 'web';
377
+ // the hosts' email is queued with the RSVP: a duplicate or a replay inserts nothing, so it emails no one
378
+ const data = {
379
+ kind: 'rsvp' as const, resourceId: '', linkId: '', eventId: e.id, status: (e.rsvp.autoConfirm ? 'confirmed' : 'pending') as BookingStatus,
299
380
  start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
300
- formResponse, notes: '', manageToken: token(), source: args.source ?? 'web', locale: normalizePageLocale(args.booker.locale ?? ''),
301
- }, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency });
381
+ formResponse, notes: '', manageToken: token(), source, locale: normalizePageLocale(args.booker.locale ?? ''),
382
+ };
383
+ let b = await repo.createBooking(data, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency, staff: await rsvpStaff(repo, e, settings, source) });
384
+ // full: a place in line, which takes no seats and emails no staff (still one per address and date)
385
+ if (!b && args.waitlist && waitlistOn(e) && !(opts.idempotency && await repo.getBookingRequest(opts.idempotency.keyHash))) {
386
+ b = await repo.createBooking({ ...data, status: 'waitlisted' }, { oncePerEmail: once, idempotency: opts.idempotency });
387
+ }
302
388
  if (!b) {
303
389
  // a retry of a request that just committed: the caller replays that one
304
390
  const retry = opts.idempotency ? await repo.getBookingRequest(opts.idempotency.keyHash) : null;
305
- const mine = once && !retry ? (await repo.listBookings({ eventId: e.id, email: booker.email, from: args.start - 1, to: args.start + 1, statuses: ['pending', 'confirmed'], kinds: ['rsvp'] })).find((x) => x.start === args.start) : undefined;
391
+ const mine = once && !retry ? (await repo.listBookings({ eventId: e.id, email: booker.email, from: args.start - 1, to: args.start + 1, statuses: ['pending', 'confirmed', 'offered', 'waitlisted'], kinds: ['rsvp'] })).find((x) => x.start === args.start) : undefined;
306
392
  if (mine) {
307
393
  await resendManageLink(repo, mine, args.booker.locale === undefined ? mine.locale : normalizePageLocale(args.booker.locale), opts);
308
394
  // anyone can type an address: say only that it has one
@@ -310,12 +396,101 @@ export async function createRsvp(
310
396
  }
311
397
  throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
312
398
  }
313
- await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
399
+ await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received', 'staff-rsvp'], opts);
314
400
  await hook(repo, 'booking.created', b, opts);
315
401
  if (b.status === 'confirmed') await hook(repo, 'booking.confirmed', b, opts);
316
402
  return b;
317
403
  }
318
404
 
405
+ /**
406
+ * Offers a date's freed seats to its waitlist, in order: each person whose
407
+ * seats fit what's free gets them held for WAITLIST_HOLD, with an email to
408
+ * confirm; larger requests keep their place. Runs after anything that frees
409
+ * seats (a cancellation, a decline, a spot that ran out, a capacity raised)
410
+ * and from the scheduler. Only while the event keeps a waitlist and before
411
+ * the date starts, past its RSVP deadline too: those in line joined in time.
412
+ * Each offer re-checks the seats in the database, so racing runs (or a new
413
+ * RSVP) never take more than the capacity. Returns how many it offered.
414
+ */
415
+ export async function offerWaitlist(repo: ContentRepository, eventId: string, start: number, opts: FlowOptions): Promise<number> {
416
+ const now = opts.now ?? Date.now();
417
+ if (start <= now) return 0;
418
+ const line = await waitlistLine(repo, eventId, start);
419
+ if (!line.length) return 0;
420
+ const e = await repo.getEvent(eventId);
421
+ // turning the waitlist off only stops new people joining: those already in line still get their turn
422
+ if (!e || !e.rsvp.enabled || e.cancelled) return 0;
423
+ if (e.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, start, await repo.getSettings()))) return 0;
424
+ const cap = e.rsvp.capacity;
425
+ const free = async () => (cap ? cap - await seatsTaken(repo, eventId, start) : Infinity);
426
+ let left = await free(), offered = 0;
427
+ for (const b of line) {
428
+ if (left <= 0) break;
429
+ if (b.seats > left) continue;
430
+ if (await repo.offerBooking(b.id, now + WAITLIST_HOLD, cap)) { left -= b.seats; offered++; }
431
+ // someone took seats meanwhile: count again
432
+ else left = await free();
433
+ }
434
+ if (offered) await kickEmail(repo, opts.locals, opts.waitUntil);
435
+ return offered;
436
+ }
437
+
438
+ /** every upcoming date of one event with people in line (after an edit that may have freed seats) */
439
+ export async function offerEventWaitlist(repo: ContentRepository, eventId: string, opts: FlowOptions): Promise<number> {
440
+ const now = opts.now ?? Date.now();
441
+ const starts = new Set((await repo.listBookings({ eventId, from: now, statuses: ['waitlisted'], kinds: ['rsvp'] })).map((b) => b.start));
442
+ let offered = 0;
443
+ for (const start of starts) offered += await offerWaitlist(repo, eventId, start, opts);
444
+ return offered;
445
+ }
446
+
447
+ /**
448
+ * The person takes the spot held for them (manage page, app): confirmed, or
449
+ * pending when the event approves each RSVP, with the usual email and the
450
+ * hosts' "New RSVP" one. Past its hold the spot is released (410) and goes
451
+ * to the next person in line. Repeating it is harmless.
452
+ */
453
+ export async function confirmOffer(repo: ContentRepository, b: Booking, opts: FlowOptions, via: Booking['source'] = 'web'): Promise<Booking> {
454
+ const now = opts.now ?? Date.now();
455
+ if (b.status === 'confirmed' || b.status === 'pending') return b;
456
+ if (b.status === 'offered' && (b.holdUntil ?? 0) <= now) {
457
+ await releaseOffer(repo, b, opts);
458
+ throw new BookingError(410, 'This held spot was released.');
459
+ }
460
+ if (b.status === 'expired') throw new BookingError(410, 'This held spot was released.');
461
+ if (b.status !== 'offered') throw new BookingError(409, 'There’s no held spot to confirm.');
462
+ if (b.end <= now) throw new BookingError(410, 'This event has already happened.');
463
+ const event = b.eventId ? await repo.getEvent(b.eventId) : null;
464
+ try {
465
+ return await setBookingStatus(repo, b, event?.rsvp.autoConfirm === false ? 'pending' : 'confirmed', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
466
+ } catch (err) {
467
+ if ((await repo.getBooking(b.id))?.status === 'expired') throw new BookingError(410, 'This held spot was released.');
468
+ throw err;
469
+ }
470
+ }
471
+
472
+ // a held spot nobody confirmed in time: expired (no email; only if it's still
473
+ // offered), and its seats go to the next person in line
474
+ async function releaseOffer(repo: ContentRepository, b: Booking, opts: FlowOptions) {
475
+ if (await repo.expireBooking(b.id, opts.now ?? Date.now())) await offerWaitlist(repo, b.eventId, b.start, opts);
476
+ }
477
+
478
+ // cron: held spots past their time run out, and every upcoming date with
479
+ // people in line is offered what's free (catching up on seats freed by edits,
480
+ // or by a change that lost a race)
481
+ export async function sweepWaitlist(repo: ContentRepository, opts: FlowOptions): Promise<number> {
482
+ const now = opts.now ?? Date.now();
483
+ const list = await repo.listBookings({ from: now, statuses: ['waitlisted', 'offered'], kinds: ['rsvp'] });
484
+ const dates = new Map<string, { eventId: string; start: number }>();
485
+ for (const b of list) {
486
+ if (b.status === 'offered' && (b.holdUntil ?? 0) <= now) await repo.expireBooking(b.id, now);
487
+ dates.set(`${b.eventId}:${b.start}`, { eventId: b.eventId, start: b.start });
488
+ }
489
+ let offered = 0;
490
+ for (const d of dates.values()) offered += await offerWaitlist(repo, d.eventId, d.start, opts);
491
+ return offered;
492
+ }
493
+
319
494
  // The address gets its RSVP's own link again, in the language it just used,
320
495
  // at most once per 10 minutes per RSVP so the form can't flood an inbox.
321
496
  async function resendManageLink(repo: ContentRepository, b: Booking, locale: string, opts: FlowOptions) {
@@ -330,9 +505,14 @@ const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
330
505
  confirmed: ['cancelled'],
331
506
  declined: ['confirmed'],
332
507
  cancelled: ['confirmed'],
508
+ // only offerWaitlist offers a place in line, and only the clock expires a held spot
509
+ waitlisted: ['cancelled'],
510
+ offered: ['confirmed', 'pending', 'cancelled'],
511
+ expired: [],
333
512
  };
334
513
 
335
- export async function setBookingStatus(repo: ContentRepository, b: Booking, status: BookingStatus, opts: FlowOptions, message?: string): Promise<Booking> {
514
+ // staff: who to tell that the person cancelled (guestCancel); staff changes tell no one
515
+ export async function setBookingStatus(repo: ContentRepository, b: Booking, status: BookingStatus, opts: FlowOptions, message?: string, staff?: RsvpStaffNotice): Promise<Booking> {
336
516
  if (b.status === status) return b;
337
517
  if (!TRANSITIONS[b.status].includes(status)) throw new BookingError(409, `A ${b.status} booking can’t become ${status}.`);
338
518
  // re-confirming a declined/cancelled booking must re-check the slot
@@ -341,13 +521,17 @@ export async function setBookingStatus(repo: ContentRepository, b: Booking, stat
341
521
  .some((x) => x.id !== b.id && x.kind !== 'rsvp' && x.holdStart < b.holdEnd && x.holdEnd > b.holdStart);
342
522
  if (clash) throw new BookingError(409, 'That time has been booked since — it can’t be restored.');
343
523
  }
344
- await repo.updateBooking(b.id, { status, notificationMessage: message });
524
+ // only from the status it was read in: a spot that ran out meanwhile can't be confirmed
525
+ await repo.updateBooking(b.id, { status, from: b.status, notificationMessage: message, staff });
345
526
  const next = (await repo.getBooking(b.id))!;
346
527
  if (!next || next.status !== status) throw new BookingError(409, 'This booking changed. Refresh and try again.');
347
528
  if (b.kind !== 'block') {
348
529
  await notify(repo, next, [status === 'confirmed' ? 'confirmed' : status === 'declined' ? 'declined' : 'cancelled'], opts, message);
349
- await hook(repo, status === 'confirmed' ? 'booking.confirmed' : status === 'declined' ? 'booking.declined' : 'booking.cancelled', next, opts);
530
+ // a held spot taken up for approval is no webhook yet (approving it is)
531
+ if (status !== 'pending') await hook(repo, status === 'confirmed' ? 'booking.confirmed' : status === 'declined' ? 'booking.declined' : 'booking.cancelled', next, opts);
350
532
  }
533
+ // the seats it held go to the next people in line
534
+ if (b.kind === 'rsvp' && seated(b.status) && !seated(status)) await offerWaitlist(repo, b.eventId, b.start, opts);
351
535
  return next;
352
536
  }
353
537
 
@@ -27,7 +27,8 @@ export interface EventJson {
27
27
  cancelledDates: string[];
28
28
  location: CalEvent['location'];
29
29
  cta: CalEvent['cta'];
30
- rsvp?: CalEvent['rsvp'];
30
+ // notifyStaff (whether the hosts get RSVP emails) only for staff callers
31
+ rsvp?: Omit<CalEvent['rsvp'], 'notifyStaff'> & Partial<Pick<CalEvent['rsvp'], 'notifyStaff'>>;
31
32
  image: string;
32
33
  imageAlt: string;
33
34
  tags: string[];
@@ -59,7 +60,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
59
60
  cancelledDates: e.cancelledDates ?? [],
60
61
  location: e.location,
61
62
  cta: e.cta,
62
- rsvp: e.rsvp,
63
+ rsvp: opts.includePrivate ? e.rsvp : (({ notifyStaff: _, ...rsvp }) => rsvp)(e.rsvp),
63
64
  image: e.cover ? abs(origin, e.cover) : '',
64
65
  imageAlt: e.coverAlt,
65
66
  tags: e.tags.filter((t) => !t.startsWith('#')),
@@ -0,0 +1,5 @@
1
+ import { userConfig } from 'virtual:acms-config';
2
+
3
+ /** where the built-in member pages live (acms({ members })); `notify` hears about new requests */
4
+ export const MEMBERS: { enabled: boolean; base: string; notify: string[] } = { enabled: false, base: '/members', notify: [], ...(userConfig.members ?? {}) };
5
+ export const memberEventsPath = () => `${MEMBERS.base}/events`;
@@ -1,4 +1,3 @@
1
- import { userConfig } from 'virtual:acms-config';
2
1
  import type { ContentRepository } from '../repo/types';
3
2
  import type { CalEvent, EventRequest } from '../schema';
4
3
  import { EventLocation } from '../schema';
@@ -20,9 +19,10 @@ import { kickEmail } from '../email/service';
20
19
  // Everything here checks who is acting, so the built-in member pages and a
21
20
  // site's own pages (through @acms-subir/core/server) share the same rules.
22
21
 
23
- /** where the built-in member pages live (acms({ members })); `notify` hears about new requests */
24
- export const MEMBERS: { enabled: boolean; base: string; notify: string[] } = { enabled: false, base: '/members', notify: [], ...(userConfig.members ?? {}) };
25
- export const memberEventsPath = () => `${MEMBERS.base}/events`;
22
+ // acms({ members }) and the My events path live on their own, so booking
23
+ // emails (which this code depends on) can read them without an import cycle
24
+ export { MEMBERS, memberEventsPath } from './members-config';
25
+ import { MEMBERS, memberEventsPath } from './members-config';
26
26
 
27
27
  /** who is acting: currentUser() from @acms-subir/core/server fits */
28
28
  export interface Actor { id: string; can(permission: string): boolean }