@acms-subir/core 0.3.39 → 0.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.40
4
+
5
+ ### Patch Changes
6
+
7
+ - b71f8c3: New `GET /api/v1/events/:slug` (or the event's id) returns one public event and its next date, or the date on `?on=YYYY-MM-DD` as the website's links use, however far ahead it is. Apps opening an event from a link, a push or an announcement no longer get "Not found" for events more than 30 days out. Drafts and events in private calendars answer 404.
8
+ - 7fc0c3f: Each new RSVP, and each RSVP the person cancels with its link or in the app, now emails the event's hosts. Events without hosts email the contact address in Settings → Legal, or the owners when there isn't one. The email is in the site's main language: "New RSVP: Youth retreat, Fri Nov 7", then the person's name, email and phone, the date, the seats and status ("2 · Confirmed", or "2 · Waiting for your approval" when RSVPs need approving), their answers, how many seats are taken now ("30 of 40 seats taken"), a button to the RSVP in Bookings (or, for a host who can't manage bookings, such as a member who hosts a class, "Open in My events", their page for the event), and whether it came from the website, the app or staff. A cancellation says "RSVP cancelled: …" and how many seats it freed. Replying writes to the person. Staff cancelling, approving or declining an RSVP, a cancelled date or event, a second RSVP from the same email and app retries send no staff email. Each event has **Email the hosts** in its CTA card, on for every event including existing ones; **Staff notifications** in Email settings still turns off all staff emails. Editor documents carry the setting as `rsvp.notifyStaff`; the public event JSON and the app API leave it out. The privacy notice now says the event's hosts (or staff) get an email with RSVP details, so its "Last updated" date moves.
9
+
3
10
  ## 0.3.39
4
11
 
5
12
  ### Patch Changes
package/integration.mjs CHANGED
@@ -835,6 +835,7 @@ export default function acms(options = {}) {
835
835
  ['/api/v1/events', 'api/v1/events.ts'],
836
836
  ['/api/v1/calendars', 'api/v1/calendars.ts'],
837
837
  // RSVPs from apps: a date's status, then POST /bookings (room bookings there need bookings on)
838
+ ['/api/v1/events/[slug]', 'api/v1/events/[slug]/index.ts'],
838
839
  ['/api/v1/events/[slug]/rsvp', 'api/v1/events/[slug]/rsvp.ts'],
839
840
  ['/api/v1/bookings', 'api/v1/bookings.ts'],
840
841
  ['/api/calendar/events', 'api/calendar/events/index.ts'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.39",
3
+ "version": "0.3.40",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -995,7 +995,7 @@ export const server = {
995
995
  calendarIds: z.array(z.string()).max(50).optional(),
996
996
  fields: z.record(z.string().max(2000)).optional(),
997
997
  rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50),
998
- closesAt: z.string().max(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), form: z.any().optional() }).optional(),
998
+ closesAt: z.string().max(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), notifyStaff: z.boolean().optional(), form: z.any().optional() }).optional(),
999
999
  roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }).optional(),
1000
1000
  scheduledAt: nullableTs.optional(),
1001
1001
  }),
@@ -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
25
+ rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: 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 },
173
173
  roomHold: NO_HOLD,
174
174
  };
175
175
 
@@ -73,6 +73,10 @@ 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>
76
80
  </div>
77
81
  <Deadline meta={meta} onChange={onChange} />
78
82
  <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 };
13
13
  }
14
14
 
15
15
  export function registrationMode({ cta, rsvp }: RegistrationData): RegistrationMode {
@@ -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
 
@@ -75,30 +76,36 @@ interface Ctx {
75
76
  allDay?: boolean;
76
77
  /** the words' language: the booking's for guest emails, the main one for staff */
77
78
  lang?: string;
79
+ /** staff RSVP emails: the date's short form, seats taken right after, why they get it and where the person did it */
80
+ rsvpStaff?: { date: string; taken: number; capacity: number; why: RsvpStaffNotice['why']; via: Booking['source'] };
81
+ /** manageUrl is the recipient's My events page (a host without Manage bookings) */
82
+ mineLink?: boolean;
78
83
  }
79
84
 
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() });
85
+ // own: a staff email's own rows and footer instead of the details and reference
86
+ 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 } {
87
+ const rows: [string, string][] = own?.rows ?? [[w.what, c.title], [w.when, c.when], ...(c.location ? [[w.where, c.location] as [string, string]] : [])];
88
+ if (!own && c.booking.seats > 1) rows.push([w.seats, String(c.booking.seats)]);
89
+ const footer = own?.footer ?? fill(w.reference, { ref: c.booking.id.slice(0, 8).toUpperCase() });
84
90
  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
91
  <div style="max-width:520px;margin:0 auto;padding:28px 20px">
86
92
  <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>
93
+ <h1 style="font-size:22px;margin:0 0 ${intro ? 8 : 18}px">${esc(heading)}</h1>
94
+ ${intro ? `<p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>` : ''}
89
95
  <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>`)
96
+ .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
97
  .join('')}</table>
92
98
  ${c.message ? `<p style="font-size:14px;line-height:1.55;margin:18px 0 0">${esc(c.message)}</p>` : ''}
93
99
  ${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>
100
+ <p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(footer)}</p>
95
101
  </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');
102
+ const text = [heading, '', ...(intro ? [intro, ''] : []), ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '', footer].join('\n');
97
103
  return { html, text };
98
104
  }
99
105
 
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';
106
+ // link: the booking's own link again, after someone tried to make it twice.
107
+ // staff-rsvp(-cancelled): the hosts hear about a new RSVP, or one the person cancelled
108
+ export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'link' | 'staff-new' | 'staff-rsvp' | 'staff-rsvp-cancelled';
102
109
 
103
110
  export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
104
111
  const w = bookingStrings(c.lang);
@@ -106,7 +113,7 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
106
113
  const manage = { href: c.manageUrl, label: rsvp ? w.rsvp.manage : w.manage };
107
114
  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
115
  // 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) => {
116
+ const guest = (k: Exclude<BookingEmail, `staff-${string}`>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
110
117
  const copy = mailCopy(w, k, rsvp);
111
118
  const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when };
112
119
  return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(copy.heading, fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
@@ -128,5 +135,22 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
128
135
  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
136
  return { to: [], subject: fill(pending ? w.staff.pendingSubject : w.staff.bookedSubject, vars), ...m, replyTo: c.booking.email || undefined };
130
137
  }
138
+ case 'staff-rsvp':
139
+ case 'staff-rsvp-cancelled': {
140
+ const r = w.rsvpStaff, s = c.rsvpStaff!, b = c.booking;
141
+ const cancelled = kind === 'staff-rsvp-cancelled', pending = !cancelled && b.status === 'pending';
142
+ const seats = String(b.seats), count = String(s.taken);
143
+ const answers = Object.entries(b.answers).map(([k, v]) => `${k}: ${v}`).join('\n');
144
+ const rows: [string, string][] = [
145
+ [r.name, b.name], [r.email, b.email], ...(b.phone ? [[r.phone, b.phone] as [string, string]] : []), [w.when, c.when],
146
+ [w.seats, fill(cancelled ? (b.seats === 1 ? r.seatFreed : r.seatsFreed) : pending ? r.pending : r.confirmed, { seats })],
147
+ ...(answers ? [[r.answers, answers] as [string, string]] : []),
148
+ [r.now, s.capacity ? fill(r.seatsTakenOf, { count, capacity: String(s.capacity) }) : fill(s.taken === 1 ? r.seatTaken : r.seatsTaken, { count })],
149
+ ];
150
+ const vars = { title: c.title, date: s.date };
151
+ const footer = `${r.via[s.via === 'web' || s.via === 'app' ? s.via : 'staff']} · ${s.why === 'hosts' ? r.hosts : r.noHosts}`;
152
+ 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 });
153
+ return { to: [], subject: fill(cancelled ? r.cancelledSubject : r.subject, vars), ...m, replyTo: b.email || undefined };
154
+ }
131
155
  }
132
156
  }
@@ -9,6 +9,10 @@ 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';
@@ -18,6 +22,8 @@ import { bookingEmail, bookingLanguage, bookingManagePath, whenText, type Bookin
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;
@@ -88,7 +94,7 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
88
94
  const ev = raw && locale ? localizeDoc(raw, locale) : raw;
89
95
  const tz = ev ? eventTimeZone(ev, settings) : settings.timezone || 'UTC';
90
96
  return {
91
- resource: null, link: null, event: ev,
97
+ resource: null, link: null, event: ev, tz,
92
98
  title: ev?.title ?? 'Event',
93
99
  location: ev ? locationLine(ev.location) : '',
94
100
  when: whenText(b, tz, settings, ev?.allDay),
@@ -97,7 +103,7 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
97
103
  const [resource, link] = await Promise.all([repo.getResource(b.resourceId), b.linkId ? repo.getBookingLink(b.linkId) : Promise.resolve(null)]);
98
104
  const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
99
105
  return {
100
- resource, link, event: null as CalEvent | null,
106
+ resource, link, event: null as CalEvent | null, tz,
101
107
  title: [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
102
108
  location: resource?.name ?? '',
103
109
  when: whenText(b, tz, settings, link?.mode === 'day'),
@@ -114,11 +120,17 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
114
120
  };
115
121
  }
116
122
 
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 }) {
123
+ // the booking in the admin; an RSVP among its event's (a cancelled one under Cancelled)
124
+ const adminBookingUrl = (origin: string, b: Booking, kind: BookingEmail) =>
125
+ `${origin}/admin/calendar/bookings?${b.eventId ? `event=${encodeURIComponent(b.eventId)}&` : ''}${kind === 'staff-rsvp-cancelled' ? 'tab=cancelled&' : ''}b=${b.id}`;
126
+
127
+ // snapshot.locale: the language to use instead of the booking's (a link sent
128
+ // again); taken/why/via: a staff RSVP email's job (rsvpStaffJobs)
129
+ 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
130
  const site = await repo.getSettings();
131
+ const staff = kind.startsWith('staff-');
120
132
  // 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);
133
+ const language = bookingLanguage(staff ? { locale: '' } : typeof snapshot?.locale === 'string' ? { locale: snapshot.locale } : b, site);
122
134
  const settings = language.settings;
123
135
  const d = await describe(repo,b,settings,language.locale);
124
136
  if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
@@ -126,10 +138,42 @@ export async function bookingMail(repo: ContentRepository, b: Booking, kind: Boo
126
138
  try { d.location = locationLine(JSON.parse(snapshot.eventLocation || '{}')); } catch { d.location = ''; }
127
139
  d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
128
140
  }
141
+ // "Fri Nov 7": the subject's date, in the event's zone
142
+ const counts = kind === 'staff-rsvp' || kind === 'staff-rsvp-cancelled' ? {
143
+ date: new Intl.DateTimeFormat(siteLocale(settings), { weekday: 'short', month: 'short', day: 'numeric', timeZone: d.tz }).format(b.start).replace(/,/g, ''),
144
+ taken: snapshot?.taken ?? await seatsTaken(repo, b.eventId, b.start), capacity: d.event?.rsvp.capacity ?? 0,
145
+ why: snapshot?.why ?? 'hosts', via: snapshot?.via ?? b.source,
146
+ } : undefined;
147
+ // a host without Manage bookings (a member who hosts) gets their My events
148
+ // page for the event instead of an admin page they can't open
149
+ const mine = counts && d.event && recipient ? await memberHostPath(repo, d.event, recipient) : '';
129
150
  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 }),
151
+ manageUrl:mine ? origin + mine : staff ? adminBookingUrl(origin, b, kind) : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
152
+ mineLink: !!mine,
131
153
  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 });
154
+ allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang, rsvpStaff: counts });
155
+ }
156
+
157
+ /** The member's My events page for an event they host, when they can't manage bookings in the admin ('' otherwise). */
158
+ async function memberHostPath(repo: ContentRepository, e: CalEvent, email: string): Promise<string> {
159
+ const user = (await repo.listUsers()).find((u) => u.email && u.email.trim().toLowerCase() === email.trim().toLowerCase());
160
+ if (!user || !MEMBERS.enabled || !e.hostIds.includes(user.id)) return '';
161
+ return (await principalCan(repo, { user }, 'schedule.bookings')) ? '' : `${memberEventsPath()}/${encodeURIComponent(e.id)}`;
162
+ }
163
+
164
+ /**
165
+ * Who hears about an event's RSVPs (rsvp.notifyStaff): its hosts, else the
166
+ * contact email in Settings → Legal (then acms({ legal }), as legalOperator
167
+ * reads it), else the owners. Accounts that were removed, or whose role the
168
+ * site no longer has, are skipped. via: where the person did it.
169
+ */
170
+ export async function rsvpStaff(repo: ContentRepository, e: CalEvent, settings: Settings, via: Booking['source']): Promise<RsvpStaffNotice | undefined> {
171
+ if (!e.rsvp.notifyStaff) return undefined;
172
+ const contact = settings.legal.contactEmail || LEGAL.contactEmail;
173
+ const users = e.hostIds.length || !contact ? (await repo.listUsers()).filter((u) => u.email && isRole(u.role)) : [];
174
+ const hosts = users.filter((u) => e.hostIds.includes(u.id)).map((u) => u.email);
175
+ const to = hosts.length ? hosts : contact ? [contact] : users.filter((u) => u.role === 'owner').map((u) => u.email);
176
+ return { to: [...new Set(to.map((a) => a.trim().toLowerCase()))].filter(Boolean), why: hosts.length ? 'hosts' : 'contact', via };
133
177
  }
134
178
  async function notify(repo: ContentRepository, _b: Booking, _kinds: BookingEmail[], opts: FlowOptions, _message?: string) {
135
179
  // Repository mutations already committed their outbox jobs atomically.
@@ -205,12 +249,15 @@ export function guestChanges(b: Booking, link: BookingLink | null, now = Date.no
205
249
  };
206
250
  }
207
251
 
208
- export async function guestCancel(repo: ContentRepository, b: Booking, opts: FlowOptions): Promise<Booking> {
252
+ // via: where they cancelled (the manage page, or an app)
253
+ export async function guestCancel(repo: ContentRepository, b: Booking, opts: FlowOptions, via: Booking['source'] = 'web'): Promise<Booking> {
209
254
  const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
210
255
  if (b.status === 'cancelled') return b;
211
256
  const can = guestChanges(b, link, opts.now);
212
257
  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);
258
+ // the person cancelled an RSVP: its hosts hear about it (staff cancelling never emails staff)
259
+ const event = b.kind === 'rsvp' && b.eventId ? await repo.getEvent(b.eventId) : null;
260
+ return setBookingStatus(repo, b, 'cancelled', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
214
261
  }
215
262
 
216
263
  /** the link as a guest moving a booking sees it: no new time inside the change cutoff */
@@ -294,11 +341,13 @@ export async function createRsvp(
294
341
  if (formResponse) booker.answers = displayAnswers(formResponse);
295
342
  // one RSVP per address and date on public channels; staff may add more
296
343
  const once = args.source !== 'admin';
344
+ const source = args.source ?? 'web';
345
+ // the hosts' email is queued with the RSVP: a duplicate or a replay inserts nothing, so it emails no one
297
346
  const b = await repo.createBooking({
298
347
  kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
299
348
  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 });
349
+ formResponse, notes: '', manageToken: token(), source, locale: normalizePageLocale(args.booker.locale ?? ''),
350
+ }, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency, staff: await rsvpStaff(repo, e, settings, source) });
302
351
  if (!b) {
303
352
  // a retry of a request that just committed: the caller replays that one
304
353
  const retry = opts.idempotency ? await repo.getBookingRequest(opts.idempotency.keyHash) : null;
@@ -310,7 +359,7 @@ export async function createRsvp(
310
359
  }
311
360
  throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
312
361
  }
313
- await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
362
+ await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received', 'staff-rsvp'], opts);
314
363
  await hook(repo, 'booking.created', b, opts);
315
364
  if (b.status === 'confirmed') await hook(repo, 'booking.confirmed', b, opts);
316
365
  return b;
@@ -332,7 +381,8 @@ const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
332
381
  cancelled: ['confirmed'],
333
382
  };
334
383
 
335
- export async function setBookingStatus(repo: ContentRepository, b: Booking, status: BookingStatus, opts: FlowOptions, message?: string): Promise<Booking> {
384
+ // staff: who to tell that the person cancelled (guestCancel); staff changes tell no one
385
+ export async function setBookingStatus(repo: ContentRepository, b: Booking, status: BookingStatus, opts: FlowOptions, message?: string, staff?: RsvpStaffNotice): Promise<Booking> {
336
386
  if (b.status === status) return b;
337
387
  if (!TRANSITIONS[b.status].includes(status)) throw new BookingError(409, `A ${b.status} booking can’t become ${status}.`);
338
388
  // re-confirming a declined/cancelled booking must re-check the slot
@@ -341,7 +391,7 @@ export async function setBookingStatus(repo: ContentRepository, b: Booking, stat
341
391
  .some((x) => x.id !== b.id && x.kind !== 'rsvp' && x.holdStart < b.holdEnd && x.holdEnd > b.holdStart);
342
392
  if (clash) throw new BookingError(409, 'That time has been booked since — it can’t be restored.');
343
393
  }
344
- await repo.updateBooking(b.id, { status, notificationMessage: message });
394
+ await repo.updateBooking(b.id, { status, notificationMessage: message, staff });
345
395
  const next = (await repo.getBooking(b.id))!;
346
396
  if (!next || next.status !== status) throw new BookingError(409, 'This booking changed. Refresh and try again.');
347
397
  if (b.kind !== 'block') {
@@ -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 }
@@ -37,10 +37,10 @@ export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blo
37
37
  // location (see mergeLocationInput) — older clients send name/address/url only
38
38
  // cancelledDates is left out: single dates are cancelled at once through
39
39
  // cancelOccurrence/restoreOccurrence (lib/calendar/schedule.ts), never staged
40
- // rsvp may leave out the deadline (older clients): the current one stays
40
+ // rsvp may leave out the deadline and notifyStaff (older clients): the current ones stay
41
41
  export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location' | 'cancelledDates' | 'rsvp'>> & {
42
42
  location?: Partial<EventLocation>;
43
- rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore'>>;
43
+ rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff'>>;
44
44
  };
45
45
  type EventClean = Omit<EventSave, 'location' | 'rsvp'> & { location?: EventLocation; rsvp?: CalEvent['rsvp'] };
46
46
 
@@ -136,9 +136,11 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
136
136
  const out: EventClean = { ...rest };
137
137
  if (rsvp) {
138
138
  // Older editor clients resend the original RSVP shape. Omission must not
139
- // erase questions or a deadline that were authored in a newer editor.
139
+ // erase questions, a deadline or the staff emails setting that were
140
+ // authored in a newer editor.
140
141
  const previous = mergeEventDraft(before).rsvp;
141
- const next: CalEvent['rsvp'] = { ...rsvp, closesAt: rsvp.closesAt === undefined ? previous.closesAt ?? null : rsvp.closesAt || null, closeBefore: rsvp.closeBefore ?? previous.closeBefore ?? 0 };
142
+ const next: CalEvent['rsvp'] = { ...rsvp, closesAt: rsvp.closesAt === undefined ? previous.closesAt ?? null : rsvp.closesAt || null, closeBefore: rsvp.closeBefore ?? previous.closeBefore ?? 0,
143
+ notifyStaff: rsvp.notifyStaff ?? previous.notifyStaff ?? true };
142
144
  if (!Object.hasOwn(rsvp, 'form') && previous.form) next.form = previous.form;
143
145
  if (next.form !== undefined) {
144
146
  const form = FormDefinition.safeParse(next.form);
@@ -153,6 +155,7 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
153
155
  if (!Number.isInteger(next.closeBefore) || next.closeBefore < 0 || next.closeBefore > 60 * 1440) {
154
156
  throw new ApiError(400, 'The RSVP deadline can be at most 60 days before each date (closeBefore: 0–86400 minutes).');
155
157
  }
158
+ if (typeof next.notifyStaff !== 'boolean') throw new ApiError(400, 'rsvp.notifyStaff must be true or false.');
156
159
  out.rsvp = next;
157
160
  }
158
161
  if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
package/src/lib/drafts.ts CHANGED
@@ -52,8 +52,8 @@ export function pickEventDraft(draft: Record<string, unknown>): Partial<Pick<Cal
52
52
  const loc = EventLocation.safeParse(out.location);
53
53
  if (loc.success) out.location = loc.data; else delete out.location;
54
54
  }
55
- // and before RSVP deadlines: none
56
- if (out.rsvp) out.rsvp = { ...out.rsvp, closesAt: out.rsvp.closesAt ?? null, closeBefore: out.rsvp.closeBefore ?? 0 };
55
+ // and before RSVP deadlines: none; before staff RSVP emails: on
56
+ if (out.rsvp) out.rsvp = { ...out.rsvp, closesAt: out.rsvp.closesAt ?? null, closeBefore: out.rsvp.closeBefore ?? 0, notifyStaff: out.rsvp.notifyStaff ?? true };
57
57
  return out;
58
58
  }
59
59
  export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
@@ -160,9 +160,10 @@ export const EventFields = z.object({ ...common, excerpt: text(1000), tags: z.ar
160
160
  }).partial({ lat: true, lng: true, placeId: true, showMap: true, mapImage: true, mapImageDark: true }).strict(),
161
161
  cta: z.object({ label: text(120), href: text(1000), style: z.enum(['primary', 'outline']) }).strict(),
162
162
  calendarIds: z.array(text(100)).max(50), fields: z.record(text(2000)),
163
- // closesAt: wall time in the event's zone ('YYYY-MM-DDTHH:mm'); closeBefore: minutes before each date. Left out = kept
163
+ // closesAt: wall time in the event's zone ('YYYY-MM-DDTHH:mm'); closeBefore: minutes before each date;
164
+ // notifyStaff: the hosts get an email about each new or cancelled RSVP. Left out = kept
164
165
  rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50),
165
- closesAt: text(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), form: FormDefinition.optional() }).strict(),
166
+ closesAt: text(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), notifyStaff: z.boolean().optional(), form: FormDefinition.optional() }).strict(),
166
167
  }).partial().strict();
167
168
  export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
168
169
 
@@ -47,7 +47,7 @@ export const EditorDocument = EditorDocumentSummary.extend({
47
47
  event: z.object({
48
48
  start: str, end: str, allDay: bool, timezone: str, rrule: str, exdates: z.array(str), cancelled: bool,
49
49
  location: z.object({ name: str, address: str, url: str, lat: num.nullable(), lng: num.nullable(), placeId: str, showMap: bool, mapImage: str, mapImageDark: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
50
- calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, closesAt: str.nullable(), closeBefore: num, form: FormDefinition.optional() }),
50
+ calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, closesAt: str.nullable(), closeBefore: num, notifyStaff: bool, form: FormDefinition.optional() }),
51
51
  }).nullable(),
52
52
  languages: z.array(str),
53
53
  });
@@ -25,6 +25,22 @@ export interface BookingStrings {
25
25
  /** a pending booking nobody answered in time, declined automatically */
26
26
  lateDecline: string;
27
27
  staff: { pending: string; booked: string; review: string; open: string; pendingSubject: string; bookedSubject: string };
28
+ /**
29
+ * the hosts' email about an RSVP (or the contact address's, for an event
30
+ * without hosts): a new one, or one the person cancelled. {seats}, {count}
31
+ * and {capacity} are numbers, {date} the date's short form ("Fri Nov 7");
32
+ * the footer is `via` · `hosts` or `noHosts`.
33
+ */
34
+ rsvpStaff: {
35
+ subject: string; cancelledSubject: string; heading: string; cancelledHeading: string;
36
+ name: string; email: string; phone: string; answers: string; now: string;
37
+ confirmed: string; pending: string; seatFreed: string; seatsFreed: string;
38
+ seatTaken: string; seatsTaken: string; seatsTakenOf: string;
39
+ via: Record<'web' | 'app' | 'staff', string>;
40
+ hosts: string; noHosts: string;
41
+ /** the button for a host without Manage bookings: their My events page */
42
+ openMine: string;
43
+ };
28
44
  /** a room booking's own page: what differs from an RSVP's (rsvp-words.ts) */
29
45
  page: {
30
46
  untitled: string;
@@ -60,6 +76,14 @@ const STRINGS: Record<string, BookingStrings> = {
60
76
  what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
61
77
  lateDecline: 'We weren’t able to confirm this booking in time. Please pick another time.',
62
78
  staff: { pending: 'New request to approve', booked: 'New booking', review: 'Review in the admin', open: 'Open in the admin', pendingSubject: 'Approve: {title} — {when}', bookedSubject: 'New booking: {title} — {when}' },
79
+ rsvpStaff: {
80
+ subject: 'New RSVP: {title}, {date}', cancelledSubject: 'RSVP cancelled: {title}, {date}', heading: 'New RSVP for {title}', cancelledHeading: 'RSVP cancelled for {title}',
81
+ name: 'Name', email: 'Email', phone: 'Phone', answers: 'Answers', now: 'Now',
82
+ confirmed: '{seats} · Confirmed', pending: '{seats} · Waiting for your approval', seatFreed: '{seats} freed', seatsFreed: '{seats} freed',
83
+ seatTaken: '{count} seat taken', seatsTaken: '{count} seats taken', seatsTakenOf: '{count} of {capacity} seats taken',
84
+ via: { web: 'Sent from the website', app: 'Sent from the app', staff: 'Added by staff' }, openMine: 'Open in My events',
85
+ hosts: 'You get these because you host this event.', noHosts: 'You get these because no one hosts this event.',
86
+ },
63
87
  page: {
64
88
  untitled: 'Booking', booked: 'You’re booked', yours: 'Your booking', moved: 'Booking moved', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
65
89
  declined: 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.', cancelledText: 'This booking is cancelled.',
@@ -93,6 +117,14 @@ const STRINGS: Record<string, BookingStrings> = {
93
117
  what: 'Qué', when: 'Cuándo', where: 'Dónde', seats: 'Lugares', reference: 'Referencia {ref}', icsManage: 'Ver o cambiar: {url}',
94
118
  lateDecline: 'No pudimos confirmar esta reservación a tiempo. Por favor, elija otro horario.',
95
119
  staff: { pending: 'Nueva solicitud por aprobar', booked: 'Nueva reservación', review: 'Revisar en el panel', open: 'Abrir en el panel', pendingSubject: 'Por aprobar: {title} — {when}', bookedSubject: 'Nueva reservación: {title} — {when}' },
120
+ rsvpStaff: {
121
+ subject: 'Nueva confirmación: {title}, {date}', cancelledSubject: 'Confirmación cancelada: {title}, {date}', heading: 'Nueva confirmación para {title}', cancelledHeading: 'Confirmación cancelada para {title}',
122
+ name: 'Nombre', email: 'Correo', phone: 'Teléfono', answers: 'Respuestas', now: 'Ahora',
123
+ confirmed: '{seats} · Confirmado', pending: '{seats} · Esperando su aprobación', seatFreed: '{seats} liberado', seatsFreed: '{seats} liberados',
124
+ seatTaken: '{count} lugar ocupado', seatsTaken: '{count} lugares ocupados', seatsTakenOf: '{count} de {capacity} lugares ocupados',
125
+ via: { web: 'Enviado desde el sitio web', app: 'Enviado desde la app', staff: 'Agregado por el equipo' }, openMine: 'Abrir en Mis eventos',
126
+ hosts: 'Recibe estos correos porque usted organiza este evento.', noHosts: 'Recibe estos correos porque este evento no tiene organizadores.',
127
+ },
96
128
  page: {
97
129
  untitled: 'Reservación', booked: 'Tu reservación está confirmada', yours: 'Tu reservación', moved: 'Tu reservación cambió de horario', notAvailable: 'No disponible', cancelledTitle: 'Reservación cancelada',
98
130
  declined: 'Lo sentimos, no se pudo confirmar este horario. Puedes elegir otro.', cancelledText: 'Esta reservación se canceló.',
@@ -126,6 +158,14 @@ const STRINGS: Record<string, BookingStrings> = {
126
158
  what: 'Quoi', when: 'Quand', where: 'Où', seats: 'Places', reference: 'Référence {ref}', icsManage: 'Gérer : {url}',
127
159
  lateDecline: 'Nous n’avons pas pu confirmer cette réservation à temps. Veuillez choisir un autre horaire.',
128
160
  staff: { pending: 'Nouvelle demande à valider', booked: 'Nouvelle réservation', review: 'Examiner dans l’administration', open: 'Ouvrir dans l’administration', pendingSubject: 'À valider : {title} — {when}', bookedSubject: 'Nouvelle réservation : {title} — {when}' },
161
+ rsvpStaff: {
162
+ subject: 'Nouvelle inscription : {title}, {date}', cancelledSubject: 'Inscription annulée : {title}, {date}', heading: 'Nouvelle inscription à {title}', cancelledHeading: 'Inscription à {title} annulée',
163
+ name: 'Nom', email: 'E-mail', phone: 'Téléphone', answers: 'Réponses', now: 'Maintenant',
164
+ confirmed: '{seats} · Confirmée', pending: '{seats} · En attente de votre validation', seatFreed: '{seats} libérée', seatsFreed: '{seats} libérées',
165
+ seatTaken: '{count} place prise', seatsTaken: '{count} places prises', seatsTakenOf: '{count} sur {capacity} places prises',
166
+ via: { web: 'Envoyée depuis le site', app: 'Envoyée depuis l’app', staff: 'Ajoutée par l’équipe' }, openMine: 'Ouvrir dans Mes événements',
167
+ hosts: 'Vous recevez ces e-mails parce que vous organisez cet événement.', noHosts: 'Vous recevez ces e-mails parce que cet événement n’a pas d’organisateur.',
168
+ },
129
169
  page: {
130
170
  untitled: 'Réservation', booked: 'Votre réservation est confirmée', yours: 'Votre réservation', moved: 'Réservation déplacée', notAvailable: 'Indisponible', cancelledTitle: 'Réservation annulée',
131
171
  declined: 'Désolés, ce créneau n’a pas pu être confirmé. N’hésitez pas à en choisir un autre.', cancelledText: 'Cette réservation est annulée.',
@@ -159,6 +199,14 @@ const STRINGS: Record<string, BookingStrings> = {
159
199
  what: 'Was', when: 'Wann', where: 'Wo', seats: 'Plätze', reference: 'Referenz {ref}', icsManage: 'Verwalten: {url}',
160
200
  lateDecline: 'Wir konnten diese Buchung nicht rechtzeitig bestätigen. Bitte wählen Sie einen anderen Termin.',
161
201
  staff: { pending: 'Neue Anfrage zur Freigabe', booked: 'Neue Buchung', review: 'In der Verwaltung prüfen', open: 'In der Verwaltung öffnen', pendingSubject: 'Freigeben: {title} — {when}', bookedSubject: 'Neue Buchung: {title} — {when}' },
202
+ rsvpStaff: {
203
+ subject: 'Neue Anmeldung: {title}, {date}', cancelledSubject: 'Anmeldung storniert: {title}, {date}', heading: 'Neue Anmeldung für {title}', cancelledHeading: 'Anmeldung für {title} storniert',
204
+ name: 'Name', email: 'E-Mail', phone: 'Telefon', answers: 'Antworten', now: 'Jetzt',
205
+ confirmed: '{seats} · Bestätigt', pending: '{seats} · Wartet auf Ihre Freigabe', seatFreed: '{seats} frei geworden', seatsFreed: '{seats} frei geworden',
206
+ seatTaken: '{count} Platz belegt', seatsTaken: '{count} Plätze belegt', seatsTakenOf: '{count} von {capacity} Plätzen belegt',
207
+ via: { web: 'Über die Website gesendet', app: 'Über die App gesendet', staff: 'Vom Team eingetragen' }, openMine: 'In Meine Veranstaltungen öffnen',
208
+ hosts: 'Sie erhalten diese E-Mails, weil Sie diese Veranstaltung betreuen.', noHosts: 'Sie erhalten diese E-Mails, weil niemand diese Veranstaltung betreut.',
209
+ },
162
210
  page: {
163
211
  untitled: 'Buchung', booked: 'Ihre Buchung ist bestätigt', yours: 'Ihre Buchung', moved: 'Buchung verschoben', notAvailable: 'Nicht verfügbar', cancelledTitle: 'Buchung storniert',
164
212
  declined: 'Leider konnte dieser Termin nicht bestätigt werden. Wählen Sie gern einen anderen.', cancelledText: 'Diese Buchung ist storniert.',
@@ -192,6 +240,14 @@ const STRINGS: Record<string, BookingStrings> = {
192
240
  what: 'O quê', when: 'Quando', where: 'Onde', seats: 'Vagas', reference: 'Referência {ref}', icsManage: 'Gerenciar: {url}',
193
241
  lateDecline: 'Não conseguimos confirmar esta reserva a tempo. Por favor, escolha outro horário.',
194
242
  staff: { pending: 'Novo pedido para aprovar', booked: 'Nova reserva', review: 'Revisar no painel', open: 'Abrir no painel', pendingSubject: 'Aprovar: {title} — {when}', bookedSubject: 'Nova reserva: {title} — {when}' },
243
+ rsvpStaff: {
244
+ subject: 'Nova inscrição: {title}, {date}', cancelledSubject: 'Inscrição cancelada: {title}, {date}', heading: 'Nova inscrição em {title}', cancelledHeading: 'Inscrição cancelada em {title}',
245
+ name: 'Nome', email: 'E-mail', phone: 'Telefone', answers: 'Respostas', now: 'Agora',
246
+ confirmed: '{seats} · Confirmada', pending: '{seats} · Aguardando sua aprovação', seatFreed: '{seats} liberada', seatsFreed: '{seats} liberadas',
247
+ seatTaken: '{count} vaga ocupada', seatsTaken: '{count} vagas ocupadas', seatsTakenOf: '{count} de {capacity} vagas ocupadas',
248
+ via: { web: 'Enviada pelo site', app: 'Enviada pelo app', staff: 'Adicionada pela equipe' }, openMine: 'Abrir em Meus eventos',
249
+ hosts: 'Você recebe estes e-mails porque organiza este evento.', noHosts: 'Você recebe estes e-mails porque este evento não tem organizadores.',
250
+ },
195
251
  page: {
196
252
  untitled: 'Reserva', booked: 'Sua reserva está confirmada', yours: 'Sua reserva', moved: 'Reserva remarcada', notAvailable: 'Indisponível', cancelledTitle: 'Reserva cancelada',
197
253
  declined: 'Desculpe, não foi possível confirmar este horário. Fique à vontade para escolher outro.', cancelledText: 'Esta reserva foi cancelada.',
@@ -22,7 +22,7 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
22
22
  const b = await repo.getBooking(payload.bookingId);
23
23
  if (!b || (b.notificationVersion ?? 0) !== payload.version) return null;
24
24
  if (payload.kind === 'reminder' && (b.status !== 'confirmed' || b.start <= Date.now())) return null;
25
- const mail = await bookingMail(repo, b, payload.kind, origin, from, payload);
25
+ const mail = await bookingMail(repo, b, payload.kind, origin, from, payload, job.recipient);
26
26
  return { ...mail, to: job.recipient };
27
27
  }
28
28
  if (job.kind === 'auth') {
@@ -1,5 +1,5 @@
1
1
  import type { SqlDriver, SqlStatement } from '../repo/sql';
2
- import type { ConfigRecord, EmailConfig, EmailJob, Invitation, NewEmailJob, SendResult } from './types';
2
+ import type { ConfigRecord, EmailConfig, EmailJob, Invitation, NewEmailJob, RsvpStaffNotice, SendResult } from './types';
3
3
  const record = (r: Record<string, unknown>): ConfigRecord => ({ version: Number(r.version), config: JSON.parse(String(r.config)), encrypted: String(r.encrypted), createdAt: Number(r.created_at) });
4
4
  function job(r: Record<string, unknown>): EmailJob {
5
5
  return { id: String(r.id), dedupe: String(r.dedupe), kind: String(r.kind), recipient: String(r.recipient), payload: String(r.payload), configVersion: Number(r.config_version), state: r.state as EmailJob['state'], attempts: Number(r.attempts), nextAt: Number(r.next_at), expiresAt: r.expires_at == null ? null : Number(r.expires_at), createdAt: Number(r.created_at), lease: String(r.lease), leaseUntil: r.lease_until == null ? null : Number(r.lease_until), provider: String(r.provider), providerId: String(r.provider_id), error: String(r.error), acceptedAt: r.accepted_at == null ? null : Number(r.accepted_at), updatedAt: Number(r.updated_at), fingerprint: String(r.fingerprint), rendered: String(r.rendered || '') };
@@ -25,6 +25,22 @@ export function bookingJobs(id: string, kind: string, version?: number, staff =
25
25
  });
26
26
  return [select('b.email', 'bookings b', kind), ...(staff ? [select('j.value', 'bookings b JOIN resources r ON r.id = b.resource_id, json_each(r.notify_emails) j', 'staff-new')] : [])];
27
27
  }
28
+ // Staff hear about an RSVP (new, or cancelled by the person) at the addresses
29
+ // the caller picked, in the same batch and only if the change landed.
30
+ // `taken` is the date's seat count right after it.
31
+ export function rsvpStaffJobs(id: string, kind: 'staff-rsvp' | 'staff-rsvp-cancelled', notice: RsvpStaffNotice | undefined, version: number, mutation = ''): SqlStatement[] {
32
+ if (!notice?.to.length) return [];
33
+ const now = Date.now();
34
+ return [{
35
+ sql: `INSERT OR IGNORE INTO email_jobs (id,dedupe,kind,recipient,payload,config_version,next_at,expires_at,created_at,updated_at)
36
+ SELECT lower(hex(randomblob(16))), b.id || ':' || b.notification_version || ':' || ? || ':' || lower(j.value), 'booking', lower(j.value),
37
+ json_object('bookingId',b.id,'version',b.notification_version,'kind',?,'why',?,'via',?,
38
+ 'taken',(SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=b.event_id AND start_at=b.start_at AND status IN ('pending','confirmed'))),
39
+ COALESCE((SELECT MAX(version) FROM email_configs),0), ?, NULL, ?, ?
40
+ FROM bookings b, json_each(?) j WHERE b.id = ? AND b.kind = 'rsvp' AND j.value != '' AND b.notification_version = ? ${mutation ? 'AND b.email_mutation = ?' : ''}`,
41
+ args: [kind, kind, notice.why, notice.via, now, now, now, JSON.stringify(notice.to), id, version, ...(mutation ? [mutation] : [])],
42
+ }];
43
+ }
28
44
  export class EmailStore {
29
45
  constructor(private getDriver: () => Promise<SqlDriver>) {}
30
46
  async config(version?: number): Promise<ConfigRecord | null> {
@@ -1,4 +1,4 @@
1
- import type { Role } from '../schema';
1
+ import type { Booking, Role } from '../schema';
2
2
  export type Provider = 'resend' | 'mailgun' | 'ses' | 'cloudflare';
3
3
  export type ProviderChoice = Provider | 'auto' | 'none';
4
4
  export interface Attachment { filename: string; type: string; content: string }
@@ -19,6 +19,8 @@ export interface EmailJob {
19
19
  acceptedAt: number | null; updatedAt: number; fingerprint: string;
20
20
  rendered: string;
21
21
  }
22
+ /** staff to tell about an RSVP (booking/service.ts rsvpStaff): to its hosts, else the contact address; via: where the person did it */
23
+ export interface RsvpStaffNotice { to: string[]; why: 'hosts' | 'contact'; via: Booking['source'] }
22
24
  export interface NewEmailJob { id?: string; dedupe: string; kind: string; recipient: string; payload: string; configVersion: number; nextAt?: number; expiresAt?: number }
23
25
  export type SendResult = { state: 'accepted'; providerId: string } | { state: 'retrying' | 'failed' | 'unknown'; error: string; retryAfter?: number };
24
26
  export interface Invitation { id: string; email: string; name: string; role: Role; state: string; expiresAt: number; createdAt: number; invitedBy: string }
@@ -42,7 +42,7 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
42
42
  ] },
43
43
  ];
44
44
  if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Bookings and RSVPs' : 'Event RSVPs', text: [
45
- `When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}.`,
45
+ `When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}. When you RSVP or cancel an RSVP, the event’s hosts (or our staff) get an email with your RSVP details.`,
46
46
  'Each booking has a private link to see, change or cancel it. Keep it private: anyone with the link can use it.',
47
47
  ] });
48
48
  s.push({ id: 'forms', title: 'Forms', text: [
@@ -43,7 +43,7 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
43
43
  ] },
44
44
  ];
45
45
  if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Reservaciones y registros' : 'Registros a eventos', text: [
46
- `Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}.`,
46
+ `Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}. Cuando se registra a un evento o cancela su registro, los organizadores del evento (o nuestro personal) reciben un correo con los datos de su registro.`,
47
47
  'Cada reservación tiene un enlace privado para verla, cambiarla o cancelarla. Manténgalo en privado: cualquiera que lo tenga puede usarlo.',
48
48
  ] });
49
49
  s.push({ id: 'forms', title: 'Formularios', text: [
@@ -4,7 +4,7 @@ import { publicTags, tagFilter, type BookingLink, type CalEvent, type Calendar,
4
4
  import { ApiError } from '../api';
5
5
  import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
6
6
  import { calendarToJson, eventToJson } from '../calendar/api';
7
- import { eventIsListed, eventIsPublic, expandEvents } from '../calendar/events';
7
+ import { eventIsListed, eventIsPublic, expandEvents, featuredOccurrence } from '../calendar/events';
8
8
  import { availabilityContext } from '../booking/service';
9
9
  import { addDaysYmd, describeLink, linkLengths, localMidnight, slotsForDay, ymd } from '../booking/availability';
10
10
  import { siteOrigin } from '../seo';
@@ -65,6 +65,28 @@ export async function listEvents(repo: ContentRepository, url: URL) {
65
65
  pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
66
66
  };
67
67
  }
68
+ // one event by slug (or id), whatever its date: apps open it from a link, a
69
+ // push or an announcement, often weeks ahead. `on` ('YYYY-MM-DD', like the
70
+ // website's ?on=) picks that day's occurrence; otherwise the next one, or the
71
+ // most recent when it's over.
72
+ export const eventDetailQuery = z.object({ locale: localeQuery, on: isoDay.optional() }).strict();
73
+ export async function getEventDetail(repo: ContentRepository, url: URL, ref: string) {
74
+ requireCalendar();
75
+ const q = eventDetailQuery.parse(queryParams(url));
76
+ const locale = mobileLocale(q.locale);
77
+ const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
78
+ const found = ref && ref.length <= 200 ? await repo.getEventBySlug(ref) ?? await repo.getEvent(ref) : null;
79
+ const all = new Map(calendars.map(c => [c.id, c]));
80
+ if (!found || !eventIsPublic(found, all)) throw new ApiError(404, 'Event not found.');
81
+ const e = localizeDoc(found, locale ?? undefined);
82
+ const o = featuredOccurrence(e, settings, Date.now(), q.on);
83
+ const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public').map(c => [c.id, c]));
84
+ return {
85
+ locale,
86
+ occurrence: o ? { id: `${e.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, cancelled: o.cancelled } : null,
87
+ event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale),
88
+ };
89
+ }
68
90
  // e is already localized; publicCalendars limits which calendars are named
69
91
  export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null) {
70
92
  const event = eventToJson(e, origin, publicCalendars, settings.timezone);
@@ -75,7 +75,7 @@ export const getManaged = async (c: PublicContext) => view(await managed(c));
75
75
 
76
76
  export async function cancelManaged(c: PublicContext) {
77
77
  const m = await managed(c);
78
- return view(m, await guestCancel(c.repo, m.booking, flow(m, c)));
78
+ return view(m, await guestCancel(c.repo, m.booking, flow(m, c), 'app'));
79
79
  }
80
80
 
81
81
  export const manageAvailabilityQuery = z.object({
@@ -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, availabilityQuery } from './calendar';
5
+ import { eventsQuery, eventDetailQuery, availabilityQuery } from './calendar';
6
6
  import { bookingInput } from './bookings';
7
7
  import { rsvpQuery } from './rsvp';
8
8
  import { pagesQuery } from './pages';
@@ -51,6 +51,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
51
51
  ['/content/{id}', 'Published content detail', contentDetailQuery, ref('Content')],
52
52
  ['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
53
53
  ['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
54
+ ['/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')],
54
55
  ['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
55
56
  ['/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],
56
57
  ['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
@@ -30,7 +30,7 @@ export const Page = ContentSummary.extend({
30
30
  export const Event = CalEvent.pick({
31
31
  id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
32
32
  allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
33
- }).extend({ rsvp: CalEvent.shape.rsvp.optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
33
+ }).extend({ rsvp: CalEvent.shape.rsvp.removeDefault().omit({ notifyStaff: true }).default({}).optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
34
34
  calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
35
35
  // push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
36
36
  notify: z.object({ changes: strings }),
@@ -40,6 +40,7 @@ export const Events = z.object({ locale, timezone: str, from: date, to: date, pa
40
40
  });
41
41
  const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
42
42
  const ContentHit = ContentSummary.extend({ snippet: Snippet });
43
+ export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event });
43
44
  export const SearchResults = z.object({ locale, query: str,
44
45
  posts: z.array(ContentHit), pages: z.array(ContentHit),
45
46
  events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event })),
@@ -11,7 +11,7 @@ import { PermissionStore } from '../permissions/store';
11
11
  import { BackupStore } from '../backup/store';
12
12
  import { LiveConfig } from '../live/types';
13
13
  import { ApiError } from '../api';
14
- import { EmailStore, bookingJobs } from '../email/store';
14
+ import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
15
15
  import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
16
16
  import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
17
17
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
@@ -1241,7 +1241,7 @@ export class SqlRepository implements ContentRepository {
1241
1241
  SELECT ?,?,id,? FROM bookings WHERE id=?`, args: [keyHash, requestHash, now, b.id] });
1242
1242
  }
1243
1243
  // Booking, retry ledger and email outbox commit together on D1 and libSQL.
1244
- await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : 'received', 0, true)]);
1244
+ await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : 'received', 0, true), ...rsvpStaffJobs(b.id, 'staff-rsvp', guard.staff, 0)]);
1245
1245
  return this.getBooking(b.id);
1246
1246
  }
1247
1247
  async moveBooking(id: string, t: { start: number; end: number; holdStart: number; holdEnd: number }) {
@@ -1278,7 +1278,8 @@ export class SqlRepository implements ContentRepository {
1278
1278
  args.push(id);
1279
1279
  const kind = data.status === 'confirmed' ? 'confirmed' : data.status === 'declined' ? 'declined' : data.status === 'pending' ? 'received' : 'cancelled';
1280
1280
  await this.driver.batch([{sql:`UPDATE bookings SET ${sets.join(', ')} WHERE id = ? AND notification_version = ?`,args:[...args,current.notificationVersion]},
1281
- ...(changed ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : [])]);
1281
+ ...(changed ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : []),
1282
+ ...(changed && data.status === 'cancelled' ? rsvpStaffJobs(id,'staff-rsvp-cancelled',data.staff,current.notificationVersion+1,mutation) : [])]);
1282
1283
  }
1283
1284
  async deleteBooking(id: string) {
1284
1285
  await this.ready;
@@ -7,6 +7,7 @@ import type { SavedForm, FormSubmission } from '../forms/schema';
7
7
  import type { LiveStore } from '../live/store';
8
8
  import type { BookingRequest } from '../backup/content';
9
9
  import type { EmailStore } from '../email/store';
10
+ import type { RsvpStaffNotice } from '../email/types';
10
11
  import type { PushStore } from '../push/store';
11
12
  import type { AnnouncementStore } from '../announcements/store';
12
13
  import type { Announcement } from '../announcements/types';
@@ -189,7 +190,8 @@ export interface ContentRepository {
189
190
  // write that can fail on a race: it inserts atomically only if the held
190
191
  // range is still free (resource bookings) or seats remain (RSVPs; with
191
192
  // oncePerEmail, and the address has no active RSVP for that date), and
192
- // returns null when it lost.
193
+ // returns null when it lost. staff: who hears about a new RSVP (queued with
194
+ // it, so a lost race or a replay emails no one).
193
195
  /** from/to: overlaps the window; endedBy: ended at or before; default order start ascending, at most 5000 rows */
194
196
  listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; endedBy?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][]; newestFirst?: boolean; limit?: number; offset?: number }): Promise<Booking[]>;
195
197
  getBooking(id: string): Promise<Booking | null>;
@@ -199,11 +201,12 @@ export interface ContentRepository {
199
201
  // locale: the booker's language; '' (the main language) when left out
200
202
  createBooking(
201
203
  data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale'> & { locale?: string },
202
- guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string } }
204
+ guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string }; staff?: RsvpStaffNotice }
203
205
  ): Promise<Booking | null>;
204
206
  // moves a booking only if the new held range is free (reschedule)
205
207
  moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
206
- updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { notificationMessage?: string }): Promise<void>;
208
+ // staff: who hears that the person cancelled their RSVP (status → cancelled only)
209
+ updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { notificationMessage?: string; staff?: RsvpStaffNotice }): Promise<void>;
207
210
  deleteBooking(id: string): Promise<void>;
208
211
  // confirmed bookings starting in [from, to) that haven't had a reminder
209
212
  listDueReminders(from: number, to: number): Promise<Booking[]>;
package/src/lib/schema.ts CHANGED
@@ -417,7 +417,9 @@ export const CalEvent = z.object({
417
417
  // RSVP: seats per occurrence (0 = unlimited) and whether RSVPs confirm at
418
418
  // once; RSVPs are stored as bookings of kind 'rsvp'. They close at closesAt
419
419
  // (wall time in the event's zone, like start; every date), else closeBefore
420
- // minutes before each date's start, else when the date ends.
420
+ // minutes before each date's start, else when the date ends. notifyStaff:
421
+ // the hosts (else the church's contact email) hear about each new RSVP and
422
+ // each one the person cancels (lib/booking/service.ts rsvpStaff).
421
423
  rsvp: z.object({
422
424
  enabled: z.boolean().default(false),
423
425
  capacity: z.number().int().default(0),
@@ -425,6 +427,7 @@ export const CalEvent = z.object({
425
427
  maxSeats: z.number().int().default(4),
426
428
  closesAt: z.string().nullable().default(null),
427
429
  closeBefore: z.number().int().default(0),
430
+ notifyStaff: z.boolean().default(true),
428
431
  form: FormDefinition.optional(),
429
432
  }).default({}),
430
433
  // rooms/people this event uses: they can't be booked while it happens (or
@@ -0,0 +1,8 @@
1
+ import { getEventDetail } from '../../../../../lib/mobile/calendar';
2
+ import { readRoute } from '../../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
5
+ // the next occurrence moves with the clock: not timeless
6
+ export const GET = readRoute(getEventDetail, true);
7
+ export const HEAD = GET;
8
+ export const ALL = () => methodNotAllowed();