@acms-subir/core 0.3.38 → 0.3.39

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 (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/docs/mobile.md +1 -1
  3. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +11 -7
  4. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +11 -7
  5. package/package.json +1 -1
  6. package/src/actions/index.ts +2 -1
  7. package/src/components/EventPage.astro +14 -5
  8. package/src/components/MobileApp.tsx +44 -23
  9. package/src/components/editor/EventPanel.tsx +2 -1
  10. package/src/components/editor/PostEditor.tsx +1 -1
  11. package/src/components/editor/event/RegistrationCard.tsx +51 -1
  12. package/src/components/editor/event/registration.ts +18 -1
  13. package/src/lib/booking/emails.ts +3 -1
  14. package/src/lib/booking/service.ts +37 -10
  15. package/src/lib/calendar/events.ts +11 -0
  16. package/src/lib/calendar/rsvp-words.ts +31 -2
  17. package/src/lib/content-service.ts +26 -14
  18. package/src/lib/drafts.ts +2 -0
  19. package/src/lib/editor/documents.ts +3 -1
  20. package/src/lib/editor/schemas.ts +1 -1
  21. package/src/lib/email/booking-words.ts +10 -1
  22. package/src/lib/mobile/bookings.ts +1 -1
  23. package/src/lib/mobile/http.ts +1 -1
  24. package/src/lib/mobile/openapi.ts +1 -1
  25. package/src/lib/mobile/rsvp.ts +14 -8
  26. package/src/lib/mobile/schemas.ts +5 -2
  27. package/src/lib/push/api.ts +7 -8
  28. package/src/lib/push/service.ts +21 -16
  29. package/src/lib/push/types.ts +18 -8
  30. package/src/lib/repo/sql.ts +3 -0
  31. package/src/lib/repo/types.ts +3 -2
  32. package/src/lib/schema.ts +5 -1
  33. package/src/pages/events/[slug].astro +10 -3
  34. package/src/styles/editor.css +4 -0
  35. package/src/styles/mobile-app.css +7 -0
  36. package/src/styles/site.css +3 -0
  37. package/src/theme-sdk/index.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.39
4
+
5
+ ### Patch Changes
6
+
7
+ - 4c33f48: **When an event changes** in **Admin → Mobile app** now takes a list of rules (up to 10), each with its own calendars, **Only events tagged** and **Send to**. Use **Add a rule** to add one and **Remove** to take one away; with no rules, change notices are off. A site can now send classes tagged "Class" in a shared rooms calendar to a `classes` topic, and every event in its Events calendar to that event's own topics (Events and its calendar). An event that several rules cover gets one notice, sent to all of their topics, so each phone gets it once. Events in the app API list those same topics in `notify.changes`. The rule you already have becomes the first rule, with nothing to redo.
8
+ - 527ba2f: Events can stop taking RSVPs before they happen. In the event's CTA card (Built-in form), a one-time event sets when RSVPs close (a date and time in the event's time zone), and a repeating one closes each date some hours or days before it starts ("Close RSVPs 2 hours before each date", up to 60 days). Until then the event page says "RSVPs close Fri, Oct 24 at 9 PM"; afterwards the form gives way to "RSVPs are closed." and "They closed on Fri, Oct 24. Questions? Contact us.", in the page's language. With no deadline, RSVPs stay open until the date ends, as before. The website and apps both turn late RSVPs away (410 in the app API). `GET /api/v1/events/:slug/rsvp` has `closesAt` (the date's deadline, or `null`) and the new reason `closed`, and events in the app API carry `rsvp.closesAt` and `rsvp.closeBefore`. Saves that leave the new settings out, from older editors and editor apps, keep them.
9
+
10
+ A second RSVP from the same email for the same date no longer takes more seats. The page says "You already have an RSVP for this date. We sent you an email with a link to change it. Need more seats? Change it there." and keeps the form, and the address gets its RSVP's link again by email, at most once every 10 minutes per RSVP, so the form can't flood someone's inbox. Nothing else about the RSVP is shown, since anyone can type an address. In the app API that's a 409 with the new error code `duplicate`. Cancelled and declined RSVPs don't count, and other dates are separate. The check is part of the same database write as the seat count, so two at once can't both get in.
11
+
12
+ The RSVP panel in the app recipes shows when RSVPs close, says when they closed, and shows the "You already have an RSVP" notice. Update it with `npx acms mobile recipe add rsvp --force`; that overwrites `components/RsvpPanel.tsx`, including any changes you made to it, and needs this release's `@acms-subir/native`. Theme event templates get `rsvp.closesAt` and `rsvp.duplicate`; a duplicate's words also come in `rsvp.error`, so templates that don't know it still say something.
13
+
3
14
  ## 0.3.38
4
15
 
5
16
  ### Patch Changes
package/docs/mobile.md CHANGED
@@ -120,7 +120,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
120
120
  | `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
121
121
  | `useEvents({ calendar, from, to, limit, q })` | `data.occurrences[]` (`start`, `end`, `allDay`, `event`) in a window (default 30 days, max 90). `from`/`to` take a site-local day (`'2026-10-01'`) or an ISO date-time with a zone (`new Date().toISOString()`) |
122
122
  | `useEvent(slug)` | one event |
123
- | `useRsvpStatus(slug, start)` / `useRsvp(slug, start)` | RSVP to one date (`start` exactly as `useEvents` returns it, a UTC ISO string) when `event.rsvp.enabled`: `open` or `reason` (`full`, `ended`, `cancelled`, `disabled`), live `going`/`left`, `maxSeats`, the questions in `form`; `answerProblems(form.fields, answers)` for inline errors. Needs only the calendar (`features.rsvp`). Keep `manageToken` in `expo-secure-store` to show or cancel the RSVP later (`useManagedBooking`, `useCancelBooking`) |
123
+ | `useRsvpStatus(slug, start)` / `useRsvp(slug, start)` | RSVP to one date (`start` exactly as `useEvents` returns it, a UTC ISO string) when `event.rsvp.enabled`: `open` or `reason` (`full`, `ended`, `closed`, `cancelled`, `disabled`), `closesAt` (the deadline; `rsvpDeadlineText` words it), live `going`/`left`, `maxSeats`, the questions in `form`; `answerProblems(form.fields, answers)` for inline errors. One RSVP per email and date: `rsvp.duplicate` means that email already has one (the site emailed its link again); say so and keep the form. Needs only the calendar (`features.rsvp`). Keep `manageToken` in `expo-secure-store` to show or cancel the RSVP later (`useManagedBooking`, `useCancelBooking`) |
124
124
  | `useManagedBooking(token)` / `useCancelBooking` / `useRescheduleBooking` / `useRescheduleAvailability` | the booker's own booking (from `book()`'s `manageToken`): `can.cancel`, `can.reschedule`, `can.closed` (change cutoff passed: show "Contact us") |
125
125
  | `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
126
126
  | `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
@@ -3,15 +3,16 @@ import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View,
3
3
  import * as Crypto from 'expo-crypto';
4
4
  import * as SecureStore from 'expo-secure-store';
5
5
  import { isAcmsError, type FormField } from '@acms-subir/client';
6
- import { Button, answerProblemText, answerProblems, countWords, fill, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
6
+ import { Button, answerProblemText, answerProblems, countWords, fill, rsvpDeadlineText, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
7
7
 
8
8
  // RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
9
9
  // with `start` exactly as useEvents returns it. Shows going and spots left,
10
10
  // seats, contact details and the event's questions, then the person's RSVP
11
11
  // with a way to cancel it; or why the date takes no RSVPs. Same rules as the
12
- // event's web page: open until the date ends, and the organiser may approve
13
- // each RSVP. useRsvp keeps one idempotency key per RSVP, so sending again
14
- // after a timeout can't register anyone twice. Its words come from
12
+ // event's web page: open until the event's deadline or the date ends, one RSVP
13
+ // per email and date, and the organiser may approve each RSVP. useRsvp keeps
14
+ // one idempotency key per RSVP, so sending again after a timeout can't
15
+ // register anyone twice. Its words come from
15
16
  // useStrings() in the app's language; the event's own form title, labels and
16
17
  // button win when the site has them.
17
18
 
@@ -91,6 +92,8 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
91
92
  const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
92
93
  const num = (n: number) => n.toLocaleString(locale);
93
94
  const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
95
+ // "RSVPs close Fri, Oct 24 at 9 PM", or once past it "They closed on …"
96
+ const deadline = rsvpDeadlineText(s, words, locale);
94
97
  const going = (
95
98
  <Text style={t.styles.text.body}>
96
99
  {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
@@ -146,7 +149,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
146
149
  if (!s.open) return (
147
150
  <View style={[t.styles.card, { gap: 6 }]}>
148
151
  <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
149
- {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
152
+ {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : s.reason === 'closed' ? deadline || words.rsvpClosed : words.dateEnded}</Text>}
150
153
  </View>
151
154
  );
152
155
 
@@ -242,6 +245,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
242
245
  <View style={{ gap: 4 }}>
243
246
  <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
244
247
  {going}
248
+ {!!deadline && <Text style={t.styles.text.muted}>{deadline}</Text>}
245
249
  {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
246
250
  {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
247
251
  </View>
@@ -263,9 +267,9 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
263
267
  {fields.map(field)}
264
268
  {error && (
265
269
  <View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
266
- <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
270
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{rsvp.duplicate ? words.alreadyRsvped : words.notSent}</Text>
267
271
  <Text style={t.styles.text.muted}>
268
- {isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
272
+ {rsvp.duplicate ? words.alreadyRsvpedNote : isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
269
273
  </Text>
270
274
  </View>
271
275
  )}
@@ -3,15 +3,16 @@ import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View,
3
3
  import * as Crypto from 'expo-crypto';
4
4
  import * as SecureStore from 'expo-secure-store';
5
5
  import { isAcmsError, type FormField } from '@acms-subir/client';
6
- import { Button, answerProblemText, answerProblems, countWords, fill, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
6
+ import { Button, answerProblemText, answerProblems, countWords, fill, rsvpDeadlineText, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
7
7
 
8
8
  // RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
9
9
  // with `start` exactly as useEvents returns it. Shows going and spots left,
10
10
  // seats, contact details and the event's questions, then the person's RSVP
11
11
  // with a way to cancel it; or why the date takes no RSVPs. Same rules as the
12
- // event's web page: open until the date ends, and the organiser may approve
13
- // each RSVP. useRsvp keeps one idempotency key per RSVP, so sending again
14
- // after a timeout can't register anyone twice. Its words come from
12
+ // event's web page: open until the event's deadline or the date ends, one RSVP
13
+ // per email and date, and the organiser may approve each RSVP. useRsvp keeps
14
+ // one idempotency key per RSVP, so sending again after a timeout can't
15
+ // register anyone twice. Its words come from
15
16
  // useStrings() in the app's language; the event's own form title, labels and
16
17
  // button win when the site has them.
17
18
 
@@ -91,6 +92,8 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
91
92
  const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
92
93
  const num = (n: number) => n.toLocaleString(locale);
93
94
  const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
95
+ // "RSVPs close Fri, Oct 24 at 9 PM", or once past it "They closed on …"
96
+ const deadline = rsvpDeadlineText(s, words, locale);
94
97
  const going = (
95
98
  <Text style={t.styles.text.body}>
96
99
  {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
@@ -146,7 +149,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
146
149
  if (!s.open) return (
147
150
  <View style={[t.styles.card, { gap: 6 }]}>
148
151
  <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
149
- {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
152
+ {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : s.reason === 'closed' ? deadline || words.rsvpClosed : words.dateEnded}</Text>}
150
153
  </View>
151
154
  );
152
155
 
@@ -242,6 +245,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
242
245
  <View style={{ gap: 4 }}>
243
246
  <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
244
247
  {going}
248
+ {!!deadline && <Text style={t.styles.text.muted}>{deadline}</Text>}
245
249
  {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
246
250
  {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
247
251
  </View>
@@ -263,9 +267,9 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
263
267
  {fields.map(field)}
264
268
  {error && (
265
269
  <View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
266
- <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
270
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{rsvp.duplicate ? words.alreadyRsvped : words.notSent}</Text>
267
271
  <Text style={t.styles.text.muted}>
268
- {isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
272
+ {rsvp.duplicate ? words.alreadyRsvpedNote : isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
269
273
  </Text>
270
274
  </View>
271
275
  )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.38",
3
+ "version": "0.3.39",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -994,7 +994,8 @@ export const server = {
994
994
  cta: z.object({ label: z.string().max(120), href: z.string().max(1000), style: z.enum(['primary', 'outline']) }).optional(),
995
995
  calendarIds: z.array(z.string()).max(50).optional(),
996
996
  fields: z.record(z.string().max(2000)).optional(),
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), form: z.any().optional() }).optional(),
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
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(),
999
1000
  scheduledAt: nullableTs.optional(),
1000
1001
  }),
@@ -11,7 +11,7 @@ import { describeRepeat } from '../lib/calendar/rrule';
11
11
  import { eventCardHtml, safeHref } from '../lib/calendar/cards';
12
12
  import { siteLang, siteLocale } from '../lib/dates';
13
13
  import { I18N, localePath, requestLocale } from '../lib/i18n';
14
- import { aroundCount, rsvpStrings } from '../lib/calendar/rsvp-words';
14
+ import { aroundCount, deadlineParts, rsvpStrings } from '../lib/calendar/rsvp-words';
15
15
  import { eventStrings } from '../lib/calendar/event-words';
16
16
  import { fill } from '../lib/live/words';
17
17
  import { eventImage } from '../lib/calendar/images';
@@ -26,8 +26,10 @@ interface Props {
26
26
  upcoming: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean })[];
27
27
  origin: string;
28
28
  preview?: boolean;
29
- // RSVP state when the event takes RSVPs (null otherwise)
30
- rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
29
+ // RSVP state when the event takes RSVPs (null otherwise). closesAt: this
30
+ // date's RSVP deadline (ms; null = open until it ends); duplicate: the
31
+ // address already has an RSVP for it (error then holds the same words)
32
+ rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; closesAt?: number | null; duplicate?: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
31
33
  }
32
34
  const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
33
35
  // the whole event, or just this date of a repeating one
@@ -78,6 +80,11 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
78
80
  const w = rsvpStrings(lang);
79
81
  const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
80
82
  const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.spotsLeft) : ['', ''];
83
+ const taking = Boolean(rsvp?.open) && (rsvp!.left === null || rsvp!.left > 0);
84
+ // "RSVPs close Fri, Oct 24 at 9 PM" while open; once past, "They closed on Fri, Oct 24."
85
+ const deadline = typeof rsvp?.closesAt === 'number' ? deadlineParts(rsvp.closesAt, tz, locale) : null;
86
+ const closesLine = deadline && taking ? fill(w.closesOn, deadline) : '';
87
+ const closedLine = deadline && !rsvp!.open && !cancelled && rsvp!.closesAt! <= Date.now() ? fill(w.closedOn, deadline) : '';
81
88
  const dateCancelled = fill(ew.dateCancelled, { date: f.date });
82
89
  const showing = fill(ew.showing, { date: f.date });
83
90
  const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: true, href: here(eventUrl(u.event.slug)) })).join('');
@@ -151,14 +158,15 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
151
158
  {rsvp ? (
152
159
  <div class="pc-ev-rsvp">
153
160
  <p class="pc-ev-going">{going[0]}<b>{rsvp.going}</b>{going[1]}{rsvp.left !== null && <> · {left[0]}<b>{rsvp.left}</b>{left[1]}</>}</p>
154
- {rsvp.open && (rsvp.left === null || rsvp.left > 0) ? (
161
+ {closesLine && <p class="pc-ev-closes">{closesLine}</p>}
162
+ {taking ? (
155
163
  <details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
156
164
  <summary class="pc-ev-cta">{w.saveSeat}</summary>
157
165
  <form method="post">
158
166
  <Fragment set:html={rsvp.formHtml ?? ''}/>
159
167
  {e.rsvp.form?.title && <h3>{e.rsvp.form.title}</h3>}
160
168
  {e.rsvp.form?.intro && <p>{e.rsvp.form.intro}</p>}
161
- {rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
169
+ {rsvp.duplicate ? <p class="pc-ev-notice" role="status"><b>{w.duplicate}</b> {w.duplicateNote}</p> : rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
162
170
  <label class="pc-bk-field"><span>{w.nameField}</span><input name="name" required maxlength="120" autocomplete="name" value={rsvp.values?.name} /></label>
163
171
  <label class="pc-bk-field"><span>{w.emailField}</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
164
172
  {rsvp.maxSeats > 1 && (
@@ -171,6 +179,7 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
171
179
  </form>
172
180
  </details>
173
181
  ) : <p class="pc-ev-full">{rsvp.open ? w.full : w.closed}</p>}
182
+ {closedLine && <p class="pc-ev-closes">{closedLine}</p>}
174
183
  </div>
175
184
  ) : e.cta.label && ctaHref && !cancelled && (
176
185
  <a class={`pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}`} href={ctaHref}>{e.cta.label}</a>
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState } from 'react';
2
2
  import type { AdminPushState } from '../lib/push/api';
3
- import type { PushAuto, PushNotification } from '../lib/push/types';
3
+ import type { ChangeRule, PushAuto, PushNotification } from '../lib/push/types';
4
4
  import '../styles/mobile-app.css';
5
5
 
6
6
  // Admin → Mobile app: send a notification to the site's own app, choose the
@@ -10,6 +10,8 @@ import '../styles/mobile-app.css';
10
10
  const ENDPOINT = '/api/push/admin';
11
11
  const CUSTOM = '__custom';
12
12
  const SOON_MINUTES = [10, 15, 30, 60, 120, 1440];
13
+ // MAX_CHANGE_RULES (lib/push/types, which brings zod)
14
+ const MAX_RULES = 10;
13
15
 
14
16
  async function api<T>(body?: Record<string, unknown>): Promise<T> {
15
17
  const res = await fetch(ENDPOINT, {
@@ -262,10 +264,14 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
262
264
  } catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(false); }
263
265
  };
264
266
  const topicName = (id: string) => state.topics.find((t) => t.id === id)?.label ?? id;
265
- const calendarChoices = (key: 'newEvent' | 'startingSoon' | 'eventChanges') => {
266
- const list = auto[key].calendars;
267
+ const uid = useId();
268
+ const addRule = useRef<HTMLButtonElement>(null);
269
+ const rules = auto.eventChanges;
270
+ const setRule = (i: number, patch: Partial<ChangeRule>) => setAuto({ ...auto, eventChanges: rules.map((r, j) => (j === i ? { ...r, ...patch } : r)) });
271
+ // focus moves to "Add a rule" (enabled again once the tenth rule goes)
272
+ const removeRule = (i: number) => { setAuto({ ...auto, eventChanges: rules.filter((_, j) => j !== i) }); requestAnimationFrame(() => addRule.current?.focus()); };
273
+ const calendarChoices = (list: string[], set: (calendars: string[]) => void) => {
267
274
  const all = list.includes('*');
268
- const set = (calendars: string[]) => setAuto({ ...auto, [key]: { ...auto[key], calendars } });
269
275
  return (
270
276
  <div className="ma-choices">
271
277
  <label className="ma-check"><input type="checkbox" checked={all} onChange={() => set(all ? [] : ['*'])} /><span>All public calendars</span></label>
@@ -296,11 +302,11 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
296
302
  <>
297
303
  <div className="ma-rule">
298
304
  <div className="ma-rule-head"><b>New event published</b><small>Name, date and place, opens the event.</small></div>
299
- {calendarChoices('newEvent')}
305
+ {calendarChoices(auto.newEvent.calendars, (calendars) => setAuto({ ...auto, newEvent: { calendars } }))}
300
306
  </div>
301
307
  <div className="ma-rule">
302
308
  <div className="ma-rule-head"><b>Starting soon</b><small>A reminder before each event time, once per date.</small></div>
303
- {calendarChoices('startingSoon')}
309
+ {calendarChoices(auto.startingSoon.calendars, (calendars) => setAuto({ ...auto, startingSoon: { ...auto.startingSoon, calendars } }))}
304
310
  <label className="field ma-lead">
305
311
  <span>Send</span>
306
312
  <select value={auto.startingSoon.minutes} onChange={(e) => setAuto({ ...auto, startingSoon: { ...auto.startingSoon, minutes: Number(e.target.value) } })}>
@@ -310,24 +316,39 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
310
316
  </div>
311
317
  <div className="ma-rule">
312
318
  <div className="ma-rule-head"><b>When an event changes</b><small>New date, time or place, or a cancellation, opens the event. Sent 10 minutes after the last edit (1 minute when it starts within 3 hours), so a quick fix doesn’t notify twice.</small></div>
313
- {calendarChoices('eventChanges')}
314
- <div className="ma-fields">
315
- <div className="field ma-lead">
316
- <span>Only events tagged</span>
317
- <TagChips tags={auto.eventChanges.tags} suggestions={state.tags}
318
- onChange={(tags) => setAuto({ ...auto, eventChanges: { ...auto.eventChanges, tags } })} />
319
+ {rules.length ? (
320
+ <div className="ma-subrules">
321
+ {/* rules have no ids; inputs are controlled, so a position is enough */}
322
+ {rules.map((r, i) => (
323
+ <div key={i} className="ma-subrule" role="group" aria-labelledby={`${uid}-rule-${i}`}>
324
+ <div className="ma-subrule-head">
325
+ <b id={`${uid}-rule-${i}`}>Rule {i + 1}</b>
326
+ <button type="button" className="btn ghost sm" aria-label={`Remove rule ${i + 1}`} onClick={() => removeRule(i)}>Remove</button>
327
+ </div>
328
+ {calendarChoices(r.calendars, (calendars) => setRule(i, { calendars }))}
329
+ <div className="ma-fields">
330
+ <div className="field ma-lead">
331
+ <span>Only events tagged</span>
332
+ <TagChips tags={r.tags} suggestions={state.tags} onChange={(tags) => setRule(i, { tags })} />
333
+ </div>
334
+ <label className="field ma-lead">
335
+ <span>Send to</span>
336
+ <select value={r.topics.join(',')} onChange={(e) => setRule(i, { topics: e.target.value ? e.target.value.split(',') : [] })}>
337
+ <option value="">The event’s topics (Events, its calendar)</option>
338
+ {/* several topics can only come from the API: keep them choosable */}
339
+ {r.topics.length > 1 && <option value={r.topics.join(',')}>{r.topics.map(topicName).join(', ')}</option>}
340
+ {state.topics.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
341
+ </select>
342
+ </label>
343
+ </div>
344
+ {!r.calendars.length && <p className="hint">Choose a calendar, or this rule is removed when you save.</p>}
345
+ </div>
346
+ ))}
319
347
  </div>
320
- <label className="field ma-lead">
321
- <span>Send to</span>
322
- <select value={auto.eventChanges.topics.join(',')}
323
- onChange={(e) => setAuto({ ...auto, eventChanges: { ...auto.eventChanges, topics: e.target.value ? e.target.value.split(',') : [] } })}>
324
- <option value="">The event’s topics (Events, its calendar)</option>
325
- {/* several topics can only come from the API: keep them choosable */}
326
- {auto.eventChanges.topics.length > 1 && <option value={auto.eventChanges.topics.join(',')}>{auto.eventChanges.topics.map(topicName).join(', ')}</option>}
327
- {state.topics.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
328
- </select>
329
- </label>
330
- </div>
348
+ ) : <p className="hint">Off. Add a rule to choose which events notify and who hears.</p>}
349
+ {rules.length > 1 && <p className="hint">An event that fits several rules gets one notice, sent to all of their topics.</p>}
350
+ <button type="button" ref={addRule} className="btn sm ma-add" disabled={rules.length >= MAX_RULES}
351
+ onClick={() => setAuto({ ...auto, eventChanges: [...rules, { calendars: [], tags: [], topics: [] }] })}>Add a rule</button>
331
352
  </div>
332
353
  </>
333
354
  )}
@@ -20,7 +20,8 @@ export interface EventMeta {
20
20
  cta: EventCta;
21
21
  calendarIds: string[];
22
22
  fields: Record<string, string>;
23
- rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; form?: FormDefinition };
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 };
24
25
  roomHold: RoomHoldValue;
25
26
  }
26
27
 
@@ -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 },
172
+ rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0 },
173
173
  roomHold: NO_HOLD,
174
174
  };
175
175
 
@@ -4,7 +4,8 @@ import type { EventCta } from '../../../lib/schema';
4
4
  import type { EventMeta } from '../EventPanel';
5
5
  import { Section } from './Section';
6
6
  import { RegistrationFormEditor } from './RegistrationFormEditor';
7
- import { ctaHrefValid, normalizeCtaHref, registrationMode, registrationPatch, type RegistrationMode } from './registration';
7
+ import { closeBeforeAmount, closeBeforeMinutes, closeBeforeUnit, ctaHrefValid, deadlineAfterEnd, normalizeCtaHref, registrationMode, registrationPatch, type CloseUnit, type RegistrationMode } from './registration';
8
+ import { datePart, timePart } from './dates';
8
9
 
9
10
  const MODES: { id: RegistrationMode; label: string; sub: string }[] = [
10
11
  { id: 'none', label: 'None', sub: 'Just the details' },
@@ -73,6 +74,7 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
73
74
  <input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.autoConfirm} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, autoConfirm: e.target.checked } })} />
74
75
  </label>
75
76
  </div>
77
+ <Deadline meta={meta} onChange={onChange} />
76
78
  <div className="pe-evr-form">
77
79
  <div className="pe-evr-form-t">
78
80
  <b>{form.title || 'Registration form'}</b>
@@ -90,6 +92,54 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
90
92
  );
91
93
  }
92
94
 
95
+ // When RSVPs close: a date and time for a one-time event, "[N] hours/days
96
+ // before each date" for a repeating one. A setting the other kind left behind
97
+ // (the event changed kind) is shown with a way to remove it, never hidden.
98
+ function Deadline({ meta, onChange }: { meta: EventMeta; onChange: (patch: Partial<EventMeta>) => void }) {
99
+ const closesAt = meta.rsvp.closesAt ?? null;
100
+ const before = meta.rsvp.closeBefore ?? 0;
101
+ const [unit, setUnit] = useState<CloseUnit>(() => closeBeforeUnit(before));
102
+ const set = (patch: Partial<EventMeta['rsvp']>) => onChange({ rsvp: { ...meta.rsvp, ...patch } });
103
+ const amount = closeBeforeAmount(before, unit);
104
+ const ahead = `${amount} ${amount === 1 ? unit.slice(0, -1) : unit}`;
105
+ const at = (wall: string) => `${new Date(`${datePart(wall)}T12:00:00Z`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' })}, ${timePart(wall)}`;
106
+ const remove = (patch: Partial<EventMeta['rsvp']>) => <button type="button" className="pe-evw-link" onClick={() => set(patch)}>Remove</button>;
107
+
108
+ if (meta.rrule) return (
109
+ <div className="pe-field pe-evr-close" role="group" aria-labelledby="ev-rsvp-close">
110
+ <span id="ev-rsvp-close">Close RSVPs</span>
111
+ <div className="pe-evr-when">
112
+ <input type="number" min={0} max={unit === 'days' ? 60 : 1440} placeholder="0" aria-label={`${unit} before each date`} value={before ? amount : ''}
113
+ onChange={(e) => set({ closeBefore: closeBeforeMinutes(Number(e.target.value), unit) })} />
114
+ <select aria-label="Unit" value={unit} onChange={(e) => { const next = e.target.value as CloseUnit; setUnit(next); if (before) set({ closeBefore: closeBeforeMinutes(amount, next) }); }}>
115
+ <option value="hours">{amount === 1 ? 'hour' : 'hours'}</option>
116
+ <option value="days">{amount === 1 ? 'day' : 'days'}</option>
117
+ </select>
118
+ <span>before each date</span>
119
+ </div>
120
+ <small className="pe-evs-hint">{before ? `Each date stops taking RSVPs ${ahead} before it starts.` : 'Leave empty to take RSVPs until each date ends.'}</small>
121
+ {closesAt && <small className="pe-evs-hint">Every date also closes {at(closesAt)}. {remove({ closesAt: null })}</small>}
122
+ </div>
123
+ );
124
+ const late = closesAt ? deadlineAfterEnd(closesAt, meta) : false;
125
+ return (
126
+ <div className="pe-field pe-evr-close" role="group" aria-labelledby="ev-rsvp-close">
127
+ <span id="ev-rsvp-close">RSVPs close</span>
128
+ <div className="pe-evr-when">
129
+ <input type="date" aria-label="RSVPs close on" max={datePart(meta.end || meta.start) || undefined} value={closesAt ? datePart(closesAt) : ''}
130
+ onChange={(e) => set({ closesAt: e.target.value ? `${e.target.value}T${(closesAt && timePart(closesAt)) || timePart(meta.start) || '12:00'}` : null })} />
131
+ <input type="time" aria-label="RSVPs close at" step={300} disabled={!closesAt} value={closesAt ? timePart(closesAt) : ''}
132
+ onChange={(e) => { if (closesAt && e.target.value) set({ closesAt: `${datePart(closesAt)}T${e.target.value}` }); }} />
133
+ {closesAt && remove({ closesAt: null })}
134
+ </div>
135
+ <small className={`pe-evs-hint ${late ? 'is-error' : ''}`}>
136
+ {!closesAt ? 'Leave empty to take RSVPs until the event ends.' : late ? 'That’s after the event ends, so RSVPs stay open until it ends.' : 'In the event’s time zone. Then the page says RSVPs are closed.'}
137
+ </small>
138
+ {!closesAt && before > 0 && <small className="pe-evs-hint">It closes {ahead} before it starts. {remove({ closeBefore: 0 })}</small>}
139
+ </div>
140
+ );
141
+ }
142
+
93
143
  // The link only reaches the event (and autosave) once it's valid, so a
94
144
  // half-typed link never blocks saving the rest. Blur fills in https://,
95
145
  // mailto: or tel: for bare domains, emails and phone numbers.
@@ -1,4 +1,5 @@
1
1
  import type { EventCta } from '../../../lib/schema';
2
+ import { toMin } from './dates';
2
3
 
3
4
  // The Registration card is one choice over two stored fields: `cta` (a link
4
5
  // button) and `rsvp` (the "Save my seat" form, which replaces the button on
@@ -8,7 +9,7 @@ export type RegistrationMode = 'none' | 'link' | 'rsvp';
8
9
 
9
10
  interface RegistrationData {
10
11
  cta: EventCta;
11
- rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number };
12
+ rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number };
12
13
  }
13
14
 
14
15
  export function registrationMode({ cta, rsvp }: RegistrationData): RegistrationMode {
@@ -37,3 +38,19 @@ export function normalizeCtaHref(raw: string): string {
37
38
  if (/^([\w-]+\.)+[a-z]{2,}(:\d+)?([/?#]\S*)?$/i.test(v)) return `https://${v}`;
38
39
  return v;
39
40
  }
41
+
42
+ // RSVP deadline. One-time events pick when RSVPs close (closesAt); repeating
43
+ // ones close each date "[N] [hours|days] before" it starts (closeBefore, in
44
+ // minutes, at most 60 days). The server checks both again on save.
45
+ export type CloseUnit = 'hours' | 'days';
46
+ export const closeBeforeUnit = (minutes: number): CloseUnit => (minutes > 0 && minutes % 1440 === 0 ? 'days' : 'hours');
47
+ export const closeBeforeAmount = (minutes: number, unit: CloseUnit) => minutes / (unit === 'days' ? 1440 : 60);
48
+ export const closeBeforeMinutes = (amount: number, unit: CloseUnit) =>
49
+ Math.min(60 * 1440, Math.max(0, Math.round((Number.isFinite(amount) ? amount : 0) * (unit === 'days' ? 1440 : 60))));
50
+
51
+ // a deadline at or after the end changes nothing: RSVPs close then anyway
52
+ export function deadlineAfterEnd(closesAt: string, when: { start: string; end: string; allDay: boolean }): boolean {
53
+ const end = when.end || when.start;
54
+ if (!closesAt || !end) return false;
55
+ return toMin(closesAt) >= (when.allDay ? toMin(end.slice(0, 10)) + 1440 : toMin(end));
56
+ }
@@ -97,7 +97,8 @@ ${button ? `<p style="margin:22px 0 0"><a href="${esc(button.href)}" style="disp
97
97
  return { html, text };
98
98
  }
99
99
 
100
- export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'staff-new';
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';
101
102
 
102
103
  export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
103
104
  const w = bookingStrings(c.lang);
@@ -118,6 +119,7 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
118
119
  case 'updated': return { ...guest('updated'), attachments: ics() };
119
120
  case 'rescheduled': return { ...guest('rescheduled'), attachments: ics() };
120
121
  case 'reminder': return guest('reminder');
122
+ case 'link': return guest('link');
121
123
  case 'staff-new': {
122
124
  const pending = c.booking.status === 'pending';
123
125
  const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
@@ -10,7 +10,7 @@ import { emitWebhook } from '../webhooks';
10
10
  import { parseFrom } from '../email/config';
11
11
  import { kickEmail } from '../email/service';
12
12
  import { eventUrl } from '../calendar/config';
13
- import { eventTimeZone, locationLine, occurrenceKey } from '../calendar/events';
13
+ import { eventTimeZone, locationLine, occurrenceKey, rsvpDeadline } from '../calendar/events';
14
14
  import { normalizePageLocale } from '../i18n';
15
15
  import { localizeDoc } from '../translations';
16
16
  import { bookingStrings } from '../email/booking-words';
@@ -28,7 +28,8 @@ export interface FlowOptions {
28
28
  }
29
29
 
30
30
  export class BookingError extends Error {
31
- constructor(public status: number, message: string) { super(message); }
31
+ // code: why, when the status alone doesn't say (apps get it as the error code)
32
+ constructor(public status: number, message: string, public code?: 'duplicate') { super(message); }
32
33
  }
33
34
 
34
35
  const token = () => {
@@ -113,10 +114,11 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
113
114
  };
114
115
  }
115
116
 
116
- export async function bookingMail(repo: ContentRepository, b: Booking, kind: BookingEmail, origin: string, from = '', snapshot?: { message?: string; eventTitle?: string; eventLocation?: string; eventAllDay?: number }) {
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 }) {
117
119
  const site = await repo.getSettings();
118
120
  // the guest hears in the language they booked in; staff in the main one
119
- const language = bookingLanguage(kind === 'staff-new' ? { locale: '' } : b, site);
121
+ const language = bookingLanguage(kind === 'staff-new' ? { locale: '' } : typeof snapshot?.locale === 'string' ? { locale: snapshot.locale } : b, site);
120
122
  const settings = language.settings;
121
123
  const d = await describe(repo,b,settings,language.locale);
122
124
  if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
@@ -125,7 +127,7 @@ export async function bookingMail(repo: ContentRepository, b: Booking, kind: Boo
125
127
  d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
126
128
  }
127
129
  return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
128
- manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin + bookingManagePath(b),
130
+ manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
129
131
  siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
130
132
  allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang });
131
133
  }
@@ -275,29 +277,54 @@ export async function createRsvp(
275
277
  opts: FlowOptions
276
278
  ): Promise<Booking> {
277
279
  const e = args.event;
280
+ const now = opts.now ?? Date.now();
278
281
  if (!e.rsvp.enabled) throw new BookingError(400, 'This event doesn’t take RSVPs.');
279
282
  if (e.cancelled) throw new BookingError(410, 'This event is cancelled.');
280
- if (args.end <= (opts.now ?? Date.now())) throw new BookingError(410, 'This event has already happened.');
281
- if (e.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, args.start, await repo.getSettings()))) {
283
+ if (args.end <= now) throw new BookingError(410, 'This event has already happened.');
284
+ const settings = await repo.getSettings();
285
+ if (e.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, args.start, settings))) {
282
286
  throw new BookingError(410, 'This date is cancelled.');
283
287
  }
288
+ const deadline = rsvpDeadline({ event: e, start: args.start, end: args.end }, settings);
289
+ if (deadline !== null && deadline <= now) throw new BookingError(410, 'RSVPs for this date are closed.');
284
290
  const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
285
291
  const booker = validateBooker(null, args.booker);
286
- await checkEmailCap(repo, booker.email, args.source, await repo.getSettings(), opts.now);
292
+ await checkEmailCap(repo, booker.email, args.source, settings, opts.now);
287
293
  const formResponse = e.rsvp.form ? await collectResponse(e.rsvp.form, args.booker.answers ?? {}, args.booker.formVersion) : undefined;
288
294
  if (formResponse) booker.answers = displayAnswers(formResponse);
295
+ // one RSVP per address and date on public channels; staff may add more
296
+ const once = args.source !== 'admin';
289
297
  const b = await repo.createBooking({
290
298
  kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
291
299
  start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
292
300
  formResponse, notes: '', manageToken: token(), source: args.source ?? 'web', locale: normalizePageLocale(args.booker.locale ?? ''),
293
- }, { capacity: e.rsvp.capacity, idempotency: opts.idempotency });
294
- if (!b) throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
301
+ }, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency });
302
+ if (!b) {
303
+ // a retry of a request that just committed: the caller replays that one
304
+ 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;
306
+ if (mine) {
307
+ await resendManageLink(repo, mine, args.booker.locale === undefined ? mine.locale : normalizePageLocale(args.booker.locale), opts);
308
+ // anyone can type an address: say only that it has one
309
+ throw new BookingError(409, 'You already have an RSVP for this date. We sent you an email with a link to change it.', 'duplicate');
310
+ }
311
+ throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
312
+ }
295
313
  await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
296
314
  await hook(repo, 'booking.created', b, opts);
297
315
  if (b.status === 'confirmed') await hook(repo, 'booking.confirmed', b, opts);
298
316
  return b;
299
317
  }
300
318
 
319
+ // The address gets its RSVP's own link again, in the language it just used,
320
+ // at most once per 10 minutes per RSVP so the form can't flood an inbox.
321
+ async function resendManageLink(repo: ContentRepository, b: Booking, locale: string, opts: FlowOptions) {
322
+ if (!await repo.email.rateLimit(`rsvp-link:${b.id}`, 1, 10 * 60_000)) return;
323
+ await repo.email.enqueue({dedupe:`${b.id}:${b.notificationVersion}:link:${Date.now()}`,kind:'booking',recipient:b.email,
324
+ payload:JSON.stringify({bookingId:b.id,version:b.notificationVersion,kind:'link',locale}),configVersion:(await repo.email.config())?.version||0,expiresAt:b.end});
325
+ await kickEmail(repo,opts.locals,opts.waitUntil);
326
+ }
327
+
301
328
  const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
302
329
  pending: ['confirmed', 'declined', 'cancelled'],
303
330
  confirmed: ['cancelled'],