@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
@@ -154,6 +154,17 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
154
154
  return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
155
155
  }
156
156
 
157
+ // When RSVPs to one date close (UTC ms), or null when they stay open until it
158
+ // ends: the event's closesAt (wall time in its zone, one instant for every
159
+ // date), else closeBefore minutes before this date's start. A deadline at or
160
+ // after the end isn't one.
161
+ export function rsvpDeadline(o: Pick<Occurrence, 'event' | 'start' | 'end'>, settings: Pick<Settings, 'timezone'>): number | null {
162
+ const { closesAt, closeBefore } = o.event.rsvp;
163
+ const w = parseWall(closesAt);
164
+ const at = w && !w.dateOnly ? wallToMs(w, eventTimeZone(o.event, settings)) : closeBefore > 0 ? o.start - closeBefore * 60_000 : null;
165
+ return at !== null && at < o.end ? at : null;
166
+ }
167
+
157
168
  // The occurrence of a series with this key (null when the series has no
158
169
  // such date: never generated, skipped by exdates, or the series moved)
159
170
  export function findOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): Occurrence | null {
@@ -1,8 +1,8 @@
1
1
  // Words of the built-in RSVP: the event page's RSVP block, the RSVP's own
2
2
  // page (book/manage) and the app's default button (GET /events/:slug/rsvp).
3
3
  // The same wording as the app's RSVP panel (packages/native strings.ts).
4
- // Plain strings with {count}, {email} and {title} slots (live/words.ts
5
- // `fill`); of a pair (`spotLeft`/`spotsLeft`), the first is for 1 and the
4
+ // Plain strings with {count}, {email}, {title}, {date} and {time} slots
5
+ // (live/words.ts `fill`); of a pair (`spotLeft`/`spotsLeft`), the first is for 1 and the
6
6
  // second for any other number. The event's own form title, questions and
7
7
  // button keep their words. Other languages fall back to English.
8
8
  export interface RsvpStrings {
@@ -21,6 +21,12 @@ export interface RsvpStrings {
21
21
  requestSeat: string;
22
22
  full: string;
23
23
  closed: string;
24
+ /** the event's RSVP deadline ({date} and {time}: deadlineParts): before, and once it passed (under `closed`) */
25
+ closesOn: string;
26
+ closedOn: string;
27
+ /** the address already has an RSVP for this date (nothing else about it is shown) */
28
+ duplicate: string;
29
+ duplicateNote: string;
24
30
  saveError: string;
25
31
  /** the RSVP's own page */
26
32
  booked: string;
@@ -53,6 +59,8 @@ const STRINGS: Record<string, RsvpStrings> = {
53
59
  personGoing: '{count} going', peopleGoing: '{count} going', spotLeft: '{count} spot left', spotsLeft: '{count} spots left',
54
60
  saveSeat: 'Save my seat', nameField: 'Your name', emailField: 'Email', seatsField: 'Seats', confirmSeat: 'Confirm my seat', requestSeat: 'Request a seat',
55
61
  full: 'This event is full.', closed: 'RSVPs are closed.', saveError: 'Couldn’t save your RSVP. Please try again.',
62
+ closesOn: 'RSVPs close {date} at {time}', closedOn: 'They closed on {date}. Questions? Contact us.',
63
+ duplicate: 'You already have an RSVP for this date.', duplicateNote: 'We sent you an email with a link to change it. Need more seats? Change it there.',
56
64
  booked: 'You’re booked', yours: 'Your booking', requestSent: 'Request sent', awaitingApproval: 'Waiting for approval', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
57
65
  emailWhenApproved: 'We’ll email {email} once it’s approved.', confirmationOnItsWay: 'A confirmation is on its way to {email}.',
58
66
  declined: 'Sorry, the organiser couldn’t confirm it.', cancelledText: 'This booking is cancelled.',
@@ -65,6 +73,8 @@ const STRINGS: Record<string, RsvpStrings> = {
65
73
  personGoing: '{count} va', peopleGoing: '{count} van', spotLeft: '{count} lugar disponible', spotsLeft: '{count} lugares disponibles',
66
74
  saveSeat: 'Apartar mi lugar', nameField: 'Tu nombre', emailField: 'Correo electrónico', seatsField: 'Lugares', confirmSeat: 'Confirmar mi lugar', requestSeat: 'Solicitar un lugar',
67
75
  full: 'Este evento está lleno.', closed: 'Las confirmaciones están cerradas.', saveError: 'No se pudo guardar tu confirmación. Inténtalo de nuevo.',
76
+ closesOn: 'Las confirmaciones cierran el {date}, {time}', closedOn: 'Cerraron el {date}. ¿Preguntas? Contáctanos.',
77
+ duplicate: 'Ya tienes una confirmación para esta fecha.', duplicateNote: 'Te enviamos un correo con un enlace para cambiarla. ¿Necesitas más lugares? Cámbiala ahí.',
68
78
  booked: 'Ya tienes tu lugar', yours: 'Tu confirmación', requestSent: 'Solicitud enviada', awaitingApproval: 'En espera de aprobación', notAvailable: 'No se pudo confirmar', cancelledTitle: 'Confirmación cancelada',
69
79
  emailWhenApproved: 'Te escribiremos a {email} cuando se apruebe.', confirmationOnItsWay: 'Te enviamos la confirmación a {email}.',
70
80
  declined: 'Lo sentimos, el organizador no pudo confirmarla.', cancelledText: 'Esta confirmación se canceló.',
@@ -77,6 +87,8 @@ const STRINGS: Record<string, RsvpStrings> = {
77
87
  personGoing: '{count} participant', peopleGoing: '{count} participants', spotLeft: '{count} place restante', spotsLeft: '{count} places restantes',
78
88
  saveSeat: 'Réserver ma place', nameField: 'Votre nom', emailField: 'E-mail', seatsField: 'Places', confirmSeat: 'Confirmer ma place', requestSeat: 'Demander une place',
79
89
  full: 'Cet événement est complet.', closed: 'Les inscriptions sont closes.', saveError: 'Impossible d’enregistrer votre inscription. Veuillez réessayer.',
90
+ closesOn: 'Les inscriptions ferment le {date} à {time}', closedOn: 'Elles ont fermé le {date}. Des questions ? Contactez-nous.',
91
+ duplicate: 'Vous avez déjà une inscription pour cette date.', duplicateNote: 'Nous vous avons envoyé un e-mail avec un lien pour la modifier. Besoin de plus de places ? Modifiez-la depuis ce lien.',
80
92
  booked: 'Votre place est réservée', yours: 'Votre inscription', requestSent: 'Demande envoyée', awaitingApproval: 'En attente de validation', notAvailable: 'Non confirmée', cancelledTitle: 'Inscription annulée',
81
93
  emailWhenApproved: 'Nous écrirons à {email} dès qu’elle sera validée.', confirmationOnItsWay: 'Une confirmation est en route vers {email}.',
82
94
  declined: 'Désolés, l’organisateur n’a pas pu la confirmer.', cancelledText: 'Cette inscription est annulée.',
@@ -89,6 +101,8 @@ const STRINGS: Record<string, RsvpStrings> = {
89
101
  personGoing: '{count} Zusage', peopleGoing: '{count} Zusagen', spotLeft: '{count} Platz frei', spotsLeft: '{count} Plätze frei',
90
102
  saveSeat: 'Platz reservieren', nameField: 'Ihr Name', emailField: 'E-Mail', seatsField: 'Plätze', confirmSeat: 'Platz bestätigen', requestSeat: 'Platz anfragen',
91
103
  full: 'Diese Veranstaltung ist ausgebucht.', closed: 'Anmeldungen sind geschlossen.', saveError: 'Ihre Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
104
+ closesOn: 'Anmeldung bis {date}, {time}', closedOn: 'Die Anmeldung endete am {date}. Fragen? Kontaktieren Sie uns.',
105
+ duplicate: 'Sie haben für diesen Termin schon eine Anmeldung.', duplicateNote: 'Wir haben Ihnen eine E-Mail mit einem Link zum Ändern geschickt. Mehr Plätze nötig? Ändern Sie sie dort.',
92
106
  booked: 'Ihr Platz ist reserviert', yours: 'Ihre Anmeldung', requestSent: 'Anfrage gesendet', awaitingApproval: 'Wartet auf Freigabe', notAvailable: 'Nicht bestätigt', cancelledTitle: 'Anmeldung storniert',
93
107
  emailWhenApproved: 'Wir schreiben an {email}, sobald sie freigegeben ist.', confirmationOnItsWay: 'Eine Bestätigung ist unterwegs an {email}.',
94
108
  declined: 'Leider konnte der Veranstalter sie nicht bestätigen.', cancelledText: 'Diese Anmeldung ist storniert.',
@@ -101,6 +115,8 @@ const STRINGS: Record<string, RsvpStrings> = {
101
115
  personGoing: '{count} confirmado', peopleGoing: '{count} confirmados', spotLeft: '{count} vaga disponível', spotsLeft: '{count} vagas disponíveis',
102
116
  saveSeat: 'Reservar minha vaga', nameField: 'Seu nome', emailField: 'E-mail', seatsField: 'Vagas', confirmSeat: 'Confirmar minha vaga', requestSeat: 'Solicitar uma vaga',
103
117
  full: 'Este evento está lotado.', closed: 'As inscrições estão encerradas.', saveError: 'Não foi possível salvar sua inscrição. Tente novamente.',
118
+ closesOn: 'As inscrições se encerram em {date}, {time}', closedOn: 'Elas se encerraram em {date}. Dúvidas? Fale com a gente.',
119
+ duplicate: 'Você já tem uma inscrição para esta data.', duplicateNote: 'Enviamos um e-mail com um link para alterá-la. Precisa de mais vagas? Altere por lá.',
104
120
  booked: 'Sua vaga está reservada', yours: 'Sua inscrição', requestSent: 'Solicitação enviada', awaitingApproval: 'Aguardando aprovação', notAvailable: 'Não confirmada', cancelledTitle: 'Inscrição cancelada',
105
121
  emailWhenApproved: 'Vamos escrever para {email} assim que for aprovada.', confirmationOnItsWay: 'Uma confirmação está a caminho de {email}.',
106
122
  declined: 'Desculpe, o organizador não pôde confirmá-la.', cancelledText: 'Esta inscrição foi cancelada.',
@@ -119,3 +135,16 @@ export function aroundCount(count: number, one: string, other: string): [string,
119
135
  const [before, after = ''] = (count === 1 ? one : other).split('{count}');
120
136
  return [before, after];
121
137
  }
138
+
139
+ /** A deadline's date and time in the event's zone for closesOn/closedOn: { date: 'Fri, Oct 24', time: '9 PM' } (minutes only when it has some). */
140
+ export function deadlineParts(at: number, tz: string, locale: string): { date: string; time: string } {
141
+ const f = (o: Intl.DateTimeFormatOptions) => {
142
+ try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: tz }); }
143
+ catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); }
144
+ };
145
+ const time = f({ hour: 'numeric', minute: '2-digit' });
146
+ const hour = f({ hour: 'numeric' }).format(at);
147
+ // "9 PM", "21 Uhr"; a bare "21" (Portuguese) keeps its minutes
148
+ const whole = time.formatToParts(at).find((p) => p.type === 'minute')?.value === '00' && /\D/.test(hour);
149
+ return { date: f({ weekday: 'short', month: 'short', day: 'numeric' }).format(at), time: whole ? hour : time.format(at) };
150
+ }
@@ -6,7 +6,7 @@ import { ApiError } from './api';
6
6
  import { deniedMessage } from './permissions/catalog';
7
7
  import { CONTENT, collectionUrl } from './content';
8
8
  import { CALENDAR } from './calendar/config';
9
- import { isValidTimeZone, normalizeEventTimes } from './calendar/events';
9
+ import { formatWall, isValidTimeZone, normalizeEventTimes, parseWall } from './calendar/events';
10
10
  import { eventChanged } from './calendar/hooks';
11
11
  import { normalizePageLocale } from './i18n';
12
12
  import { siteOrigin } from './seo';
@@ -37,10 +37,12 @@ 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
- export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location' | 'cancelledDates'>> & {
40
+ // rsvp may leave out the deadline (older clients): the current one stays
41
+ export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location' | 'cancelledDates' | 'rsvp'>> & {
41
42
  location?: Partial<EventLocation>;
43
+ rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore'>>;
42
44
  };
43
- type EventClean = Omit<EventSave, 'location'> & { location?: EventLocation };
45
+ type EventClean = Omit<EventSave, 'location' | 'rsvp'> & { location?: EventLocation; rsvp?: CalEvent['rsvp'] };
44
46
 
45
47
  const live = (status: string) => status === 'published' || status === 'scheduled';
46
48
  /** publishing, scheduling, unpublishing or applying staged edits needs Publish content */
@@ -130,18 +132,28 @@ export async function applyPageUpdate(repo: ContentRepository, id: string, hooks
130
132
  // Validate + normalize an event save. Times are checked as a PAIR against the
131
133
  // merged (staged) state so a half-edited range can't slip through.
132
134
  export async function cleanEventInput(repo: ContentRepository, data: EventSave, before: CalEvent): Promise<EventClean> {
133
- const { location, ...rest } = data;
135
+ const { location, rsvp, ...rest } = data;
134
136
  const out: EventClean = { ...rest };
135
- // Older editor clients resend the original RSVP shape. Omission must not
136
- // erase questions that were authored in a newer editor.
137
- if (out.rsvp && !Object.hasOwn(out.rsvp, 'form')) {
138
- const previous = mergeEventDraft(before).rsvp.form;
139
- if (previous) out.rsvp = { ...out.rsvp, form: previous };
140
- }
141
- if (out.rsvp?.form !== undefined) {
142
- const form = FormDefinition.safeParse(out.rsvp.form);
143
- if (!form.success) throw new ApiError(400, form.error.issues[0]?.message || 'Invalid registration form.');
144
- out.rsvp = { ...out.rsvp, form: form.data };
137
+ if (rsvp) {
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.
140
+ 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
+ if (!Object.hasOwn(rsvp, 'form') && previous.form) next.form = previous.form;
143
+ if (next.form !== undefined) {
144
+ const form = FormDefinition.safeParse(next.form);
145
+ if (!form.success) throw new ApiError(400, form.error.issues[0]?.message || 'Invalid registration form.');
146
+ next.form = form.data;
147
+ }
148
+ if (next.closesAt !== null) {
149
+ const w = parseWall(next.closesAt);
150
+ if (!w || w.dateOnly) throw new ApiError(400, 'The RSVP deadline must be a date and time (YYYY-MM-DDTHH:mm).');
151
+ next.closesAt = formatWall(w);
152
+ }
153
+ if (!Number.isInteger(next.closeBefore) || next.closeBefore < 0 || next.closeBefore > 60 * 1440) {
154
+ throw new ApiError(400, 'The RSVP deadline can be at most 60 days before each date (closeBefore: 0–86400 minutes).');
155
+ }
156
+ out.rsvp = next;
145
157
  }
146
158
  if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
147
159
  if (out.slug !== undefined) {
package/src/lib/drafts.ts CHANGED
@@ -52,6 +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
57
  return out;
56
58
  }
57
59
  export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
@@ -160,7 +160,9 @@ 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
- 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: FormDefinition.optional() }).strict(),
163
+ // closesAt: wall time in the event's zone ('YYYY-MM-DDTHH:mm'); closeBefore: minutes before each date. Left out = kept
164
+ 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(),
164
166
  }).partial().strict();
165
167
  export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
166
168
 
@@ -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, 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, form: FormDefinition.optional() }),
51
51
  }).nullable(),
52
52
  languages: z.array(str),
53
53
  });
@@ -6,7 +6,7 @@
6
6
  // {url} and {hours} slots (live/words.ts `fill`). `rsvp` rewords a few
7
7
  // messages for a seat at an event (English says "booking" for both). Staff
8
8
  // emails use the site's main language. Other languages fall back to English.
9
- export type GuestMail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'updated' | 'rescheduled' | 'reminder';
9
+ export type GuestMail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'updated' | 'rescheduled' | 'reminder' | 'link';
10
10
  export interface MailCopy { subject: string; heading: string; intro: string }
11
11
 
12
12
  export interface BookingStrings {
@@ -54,6 +54,7 @@ const STRINGS: Record<string, BookingStrings> = {
54
54
  updated: { subject: 'Updated: {title}', heading: 'Booking details updated', intro: 'Please review the latest details. Use the attached calendar file to update your calendar.' },
55
55
  rescheduled: { subject: 'New time: {title} — {when}', heading: 'Booking moved', intro: 'Your booking has a new time. Import the attached calendar file to update your calendar.' },
56
56
  reminder: { subject: 'Reminder: {title} — {when}', heading: 'Coming up soon', intro: 'A quick reminder about your booking.' },
57
+ link: { subject: 'Your booking: {title}', heading: 'You already have this booking', intro: 'We got another request for this date with your email address, so nothing changed. To see or change your booking, use the button below.' },
57
58
  },
58
59
  manage: 'View or change your booking', rsvp: { manage: 'View or change your booking' },
59
60
  what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
@@ -77,6 +78,7 @@ const STRINGS: Record<string, BookingStrings> = {
77
78
  updated: { subject: 'Actualizada: {title}', heading: 'Cambiaron los detalles de su reservación', intro: 'Revise los detalles más recientes. Use el archivo de calendario adjunto para actualizar su calendario.' },
78
79
  rescheduled: { subject: 'Nuevo horario: {title} — {when}', heading: 'Su reservación cambió de horario', intro: 'Su reservación tiene un nuevo horario. Importe el archivo de calendario adjunto para actualizar su calendario.' },
79
80
  reminder: { subject: 'Recordatorio: {title} — {when}', heading: 'Ya casi es la fecha', intro: 'Un breve recordatorio de su reservación.' },
81
+ link: { subject: 'Su reservación: {title}', heading: 'Ya tiene esta reservación', intro: 'Recibimos otra solicitud para este horario con su correo, así que no cambió nada. Para ver o cambiar su reservación, use el botón de abajo.' },
80
82
  },
81
83
  manage: 'Ver o cambiar su reservación',
82
84
  rsvp: {
@@ -86,6 +88,7 @@ const STRINGS: Record<string, BookingStrings> = {
86
88
  cancelled: { subject: 'Cancelado: {title}', heading: 'Confirmación cancelada', intro: 'Esta confirmación se canceló.' },
87
89
  updated: { subject: 'Actualizado: {title}', heading: 'Cambiaron los detalles' },
88
90
  reminder: { intro: 'Un breve recordatorio: tiene un lugar apartado.' },
91
+ link: { subject: 'Su confirmación para {title}', heading: 'Ya tiene una confirmación', intro: 'Recibimos otra confirmación para esta fecha con su correo, así que no cambió nada. Para cambiar la suya, o agregar lugares, use el botón de abajo.' },
89
92
  },
90
93
  what: 'Qué', when: 'Cuándo', where: 'Dónde', seats: 'Lugares', reference: 'Referencia {ref}', icsManage: 'Ver o cambiar: {url}',
91
94
  lateDecline: 'No pudimos confirmar esta reservación a tiempo. Por favor, elija otro horario.',
@@ -108,6 +111,7 @@ const STRINGS: Record<string, BookingStrings> = {
108
111
  updated: { subject: 'Mise à jour : {title}', heading: 'Détails de la réservation mis à jour', intro: 'Veuillez vérifier les derniers détails. Utilisez le fichier de calendrier joint pour mettre à jour votre calendrier.' },
109
112
  rescheduled: { subject: 'Nouvel horaire : {title} — {when}', heading: 'Réservation déplacée', intro: 'Votre réservation a un nouvel horaire. Importez le fichier de calendrier joint pour mettre à jour votre calendrier.' },
110
113
  reminder: { subject: 'Rappel : {title} — {when}', heading: 'C’est bientôt', intro: 'Un petit rappel de votre réservation.' },
114
+ link: { subject: 'Votre réservation : {title}', heading: 'Vous avez déjà cette réservation', intro: 'Nous avons reçu une autre demande pour ce créneau avec votre adresse e-mail, donc rien n’a changé. Pour voir ou modifier votre réservation, utilisez le bouton ci-dessous.' },
111
115
  },
112
116
  manage: 'Voir ou modifier votre réservation',
113
117
  rsvp: {
@@ -117,6 +121,7 @@ const STRINGS: Record<string, BookingStrings> = {
117
121
  cancelled: { heading: 'Inscription annulée', intro: 'Cette inscription a été annulée.' },
118
122
  updated: { heading: 'Détails mis à jour' },
119
123
  reminder: { intro: 'Un petit rappel de votre inscription.' },
124
+ link: { subject: 'Votre inscription : {title}', heading: 'Vous avez déjà une inscription', intro: 'Nous avons reçu une autre inscription pour cette date avec votre adresse e-mail, donc rien n’a changé. Pour modifier la vôtre ou ajouter des places, utilisez le bouton ci-dessous.' },
120
125
  },
121
126
  what: 'Quoi', when: 'Quand', where: 'Où', seats: 'Places', reference: 'Référence {ref}', icsManage: 'Gérer : {url}',
122
127
  lateDecline: 'Nous n’avons pas pu confirmer cette réservation à temps. Veuillez choisir un autre horaire.',
@@ -139,6 +144,7 @@ const STRINGS: Record<string, BookingStrings> = {
139
144
  updated: { subject: 'Aktualisiert: {title}', heading: 'Buchungsdetails aktualisiert', intro: 'Bitte prüfen Sie die aktuellen Details. Mit der angehängten Kalenderdatei aktualisieren Sie Ihren Kalender.' },
140
145
  rescheduled: { subject: 'Neuer Termin: {title} — {when}', heading: 'Buchung verschoben', intro: 'Ihre Buchung hat einen neuen Termin. Importieren Sie die angehängte Kalenderdatei, um Ihren Kalender zu aktualisieren.' },
141
146
  reminder: { subject: 'Erinnerung: {title} — {when}', heading: 'Bald ist es so weit', intro: 'Eine kurze Erinnerung an Ihre Buchung.' },
147
+ link: { subject: 'Ihre Buchung: {title}', heading: 'Sie haben diese Buchung schon', intro: 'Wir haben eine weitere Anfrage für diesen Termin mit Ihrer E-Mail-Adresse erhalten, es hat sich also nichts geändert. Über den Button unten sehen oder ändern Sie Ihre Buchung.' },
142
148
  },
143
149
  manage: 'Buchung ansehen oder ändern',
144
150
  rsvp: {
@@ -148,6 +154,7 @@ const STRINGS: Record<string, BookingStrings> = {
148
154
  cancelled: { heading: 'Anmeldung storniert', intro: 'Diese Anmeldung wurde storniert.' },
149
155
  updated: { heading: 'Details aktualisiert' },
150
156
  reminder: { intro: 'Eine kurze Erinnerung an Ihre Anmeldung.' },
157
+ link: { subject: 'Ihre Anmeldung: {title}', heading: 'Sie sind schon angemeldet', intro: 'Wir haben eine weitere Anmeldung für diesen Termin mit Ihrer E-Mail-Adresse erhalten, es hat sich also nichts geändert. Über den Button unten ändern Sie Ihre Anmeldung oder fügen Plätze hinzu.' },
151
158
  },
152
159
  what: 'Was', when: 'Wann', where: 'Wo', seats: 'Plätze', reference: 'Referenz {ref}', icsManage: 'Verwalten: {url}',
153
160
  lateDecline: 'Wir konnten diese Buchung nicht rechtzeitig bestätigen. Bitte wählen Sie einen anderen Termin.',
@@ -170,6 +177,7 @@ const STRINGS: Record<string, BookingStrings> = {
170
177
  updated: { subject: 'Atualizada: {title}', heading: 'Detalhes da reserva atualizados', intro: 'Confira os detalhes mais recentes. Use o arquivo de calendário anexo para atualizar seu calendário.' },
171
178
  rescheduled: { subject: 'Novo horário: {title} — {when}', heading: 'Reserva remarcada', intro: 'Sua reserva tem um novo horário. Importe o arquivo de calendário anexo para atualizar seu calendário.' },
172
179
  reminder: { subject: 'Lembrete: {title} — {when}', heading: 'Está chegando', intro: 'Um lembrete rápido sobre sua reserva.' },
180
+ link: { subject: 'Sua reserva: {title}', heading: 'Você já tem esta reserva', intro: 'Recebemos outro pedido para este horário com seu e-mail, então nada mudou. Para ver ou alterar sua reserva, use o botão abaixo.' },
173
181
  },
174
182
  manage: 'Ver ou alterar sua reserva',
175
183
  rsvp: {
@@ -179,6 +187,7 @@ const STRINGS: Record<string, BookingStrings> = {
179
187
  cancelled: { heading: 'Inscrição cancelada', intro: 'Esta inscrição foi cancelada.' },
180
188
  updated: { heading: 'Detalhes atualizados' },
181
189
  reminder: { intro: 'Um lembrete rápido sobre sua inscrição.' },
190
+ link: { subject: 'Sua inscrição em {title}', heading: 'Você já tem uma inscrição', intro: 'Recebemos outra inscrição para esta data com seu e-mail, então nada mudou. Para alterar a sua, ou adicionar vagas, use o botão abaixo.' },
182
191
  },
183
192
  what: 'O quê', when: 'Quando', where: 'Onde', seats: 'Vagas', reference: 'Referência {ref}', icsManage: 'Gerenciar: {url}',
184
193
  lateDecline: 'Não conseguimos confirmar esta reserva a tempo. Por favor, escolha outro horário.',
@@ -66,7 +66,7 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
66
66
  if (body.seats > (event.rsvp.maxSeats || 1)) throw new ApiError(400, 'Too many seats for one RSVP.');
67
67
  // the app must have seen the current questions: a changed form then answers 409, as on the website
68
68
  // (closed dates fall through to createRsvp's own answer)
69
- if (!body.formVersion && rsvpQuestions(event) && !rsvpClosed(occurrence)) throw new ApiError(400, 'This event asks questions. Send formVersion from GET /api/v1/events/{slug}/rsvp with the answers.');
69
+ if (!body.formVersion && rsvpQuestions(event) && !rsvpClosed(occurrence, settings)) throw new ApiError(400, 'This event asks questions. Send formVersion from GET /api/v1/events/{slug}/rsvp with the answers.');
70
70
  booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker, source: 'app' }, flow);
71
71
  } else {
72
72
  const resource = await repo.getResourceBySlug(body.resource);
@@ -33,7 +33,7 @@ export function failure(error: unknown) {
33
33
  const status = error instanceof ApiError || error instanceof BookingError ? error.status : error instanceof z.ZodError ? 400 : 500;
34
34
  const codes: Record<number, string> = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited', 502: 'upstream_error' };
35
35
  return Response.json({ version: 1, error: {
36
- code: codes[status] ?? 'internal_error',
36
+ code: (error instanceof BookingError && error.code) || (codes[status] ?? 'internal_error'),
37
37
  message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
38
38
  ...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
39
39
  } }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': String((error as ApiError).retryAfter ?? 60) } : {}),
@@ -79,7 +79,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
79
79
  paths['/api/v1/bookings'] = {
80
80
  post: {
81
81
  summary: 'Create a guest booking or an RSVP, or replay an existing request; RSVPs require calendar, room bookings also bookings',
82
- description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409.',
82
+ description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409. An RSVP from an email that already has one for that date answers 409 with code duplicate (and emails that RSVP’s link again); past the event’s RSVP deadline, 410.',
83
83
  parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
84
84
  requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
85
85
  responses: { ...errors,
@@ -1,9 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import type { CalEvent } from '../schema';
3
+ import type { CalEvent, Settings } from '../schema';
4
4
  import { ApiError } from '../api';
5
5
  import { answerFields } from '../forms/schema';
6
- import { eventIsPublic, expandEvents, type Occurrence } from '../calendar/events';
6
+ import { eventIsPublic, expandEvents, rsvpDeadline, type Occurrence } from '../calendar/events';
7
7
  import { seatsTaken } from '../booking/service';
8
8
  import { localizeDoc } from '../translations';
9
9
  import { siteLang } from '../dates';
@@ -15,8 +15,8 @@ import { localeQuery, mobileLocale } from './locale';
15
15
 
16
16
  // RSVP status of one date of an event, for apps: whether it takes RSVPs now,
17
17
  // live seats and the questions with their version. The same rules as the
18
- // event's web page: open until the date ends, no deadline or waitlist.
19
- // Never cached; seats change with every RSVP. POST /bookings saves one.
18
+ // event's web page: open until the event's deadline or the date ends, no
19
+ // waitlist. Never cached; seats change with every RSVP. POST /bookings saves one.
20
20
 
21
21
  /** a public event (slug or id) and its occurrence starting exactly at `start` */
22
22
  export async function rsvpOccurrence(repo: ContentRepository, ref: string, start: string): Promise<Occurrence> {
@@ -31,11 +31,13 @@ export async function rsvpOccurrence(repo: ContentRepository, ref: string, start
31
31
  }
32
32
  /** the RSVP form when it asks something; apps must then send its version */
33
33
  export const rsvpQuestions = (e: CalEvent) => e.rsvp.form && answerFields(e.rsvp.form).length ? e.rsvp.form : null;
34
- /** why this date takes no RSVPs (seats aside), or null */
35
- export function rsvpClosed(o: Occurrence, now = Date.now()) {
34
+ /** why this date takes no RSVPs (seats aside), or null: closed = past the event's deadline */
35
+ export function rsvpClosed(o: Occurrence, settings: Pick<Settings, 'timezone'>, now = Date.now()) {
36
36
  if (!o.event.rsvp.enabled) return 'disabled' as const;
37
37
  if (o.cancelled) return 'cancelled' as const;
38
38
  if (o.end <= now) return 'ended' as const;
39
+ const deadline = rsvpDeadline(o, settings);
40
+ if (deadline !== null && deadline <= now) return 'closed' as const;
39
41
  return null;
40
42
  }
41
43
 
@@ -48,24 +50,28 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
48
50
  const q = rsvpQuery.parse(queryParams(url));
49
51
  const locale = mobileLocale(q.locale);
50
52
  const o = await rsvpOccurrence(repo, slug, q.start);
53
+ const settings = await repo.getSettings();
51
54
  const { rsvp } = o.event;
52
55
  // pending and confirmed RSVPs hold seats, as in the capacity check
53
56
  const going = rsvp.enabled ? await seatsTaken(repo, o.event.id, o.start) : 0;
54
57
  const left = rsvp.capacity ? Math.max(0, rsvp.capacity - going) : null;
55
- const closed = rsvpClosed(o);
58
+ const closed = rsvpClosed(o, settings);
59
+ const deadline = rsvp.enabled ? rsvpDeadline(o, settings) : null;
56
60
  const reason = closed ?? (left === 0 ? 'full' as const : null);
57
61
  const form = rsvpQuestions(o.event);
58
62
  const title = localizeDoc(o.event, locale ?? undefined).title;
59
63
  // the built-in button speaks the app's language (the site's own without languages set up)
60
64
  let submitLabel = form?.submitLabel ?? '';
61
65
  if (form && !submitLabel) {
62
- const words = rsvpStrings(locale ?? siteLang(await repo.getSettings()));
66
+ const words = rsvpStrings(locale ?? siteLang(settings));
63
67
  submitLabel = rsvp.autoConfirm ? words.confirmSeat : words.requestSeat;
64
68
  }
65
69
  return {
66
70
  event: { id: o.event.id, slug: o.event.slug, title },
67
71
  start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
68
72
  open: !reason, reason, going, left, capacity: rsvp.capacity,
73
+ // when RSVPs to this date close, if before it ends
74
+ closesAt: deadline === null ? null : new Date(deadline).toISOString(),
69
75
  // seats one RSVP may take right now
70
76
  maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
71
77
  form: form ? { ...await formJson(form), submitLabel } : null,
@@ -72,8 +72,10 @@ export const Form = z.object({
72
72
  export const FormReceipt = z.object({ received: z.literal(true), confirmation: str });
73
73
  export const RsvpStatus = z.object({
74
74
  event: z.object({ id: str, slug: str, title: str }), start: date, end: date,
75
- open: bool, reason: z.enum(['disabled', 'ended', 'cancelled', 'full']).nullable(),
75
+ open: bool, reason: z.enum(['disabled', 'ended', 'closed', 'cancelled', 'full']).nullable(),
76
76
  going: z.number().int(), left: z.number().int().nullable(), capacity: z.number().int(), maxSeats: z.number().int(), autoConfirm: bool,
77
+ // the event's RSVP deadline for this date (null: open until it ends)
78
+ closesAt: date.nullable(),
77
79
  form: Form.omit({ id: true }).nullable(),
78
80
  });
79
81
  const GuestChanges = z.object({ cancel: bool, reschedule: bool, cutoffHours: z.number().int(), closed: bool });
@@ -135,6 +137,7 @@ export const LiveStatus = z.object({
135
137
  display: z.object({ style: z.enum(['poster', 'split', 'row']), bar: bool, header: bool, dock: bool }),
136
138
  });
137
139
  export const Error = z.object({ version: z.literal(1), error: z.object({
138
- code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error']),
140
+ // duplicate (409): that email already has an RSVP for the date
141
+ code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'duplicate', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error']),
139
142
  message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
140
143
  }) });
@@ -12,7 +12,7 @@ import { localeOnlyQuery, mobileLocale } from '../mobile/locale';
12
12
  import { EXPO_PUSH_BASE, PUSH, requirePush } from './config';
13
13
  import { expoOptions } from './expo';
14
14
  import { knownTopics, pushTopics, sendNotification, siteOriginOf, type PushRunOptions } from './service';
15
- import { PushAuto, type PushDevice } from './types';
15
+ import { MAX_CHANGE_RULES, PushAuto, type PushDevice } from './types';
16
16
 
17
17
  export const pushToken = z.string().max(250).regex(/^Expo(?:nent)?PushToken\[[A-Za-z0-9_-]{8,200}\]$/, 'Use an Expo push token such as ExponentPushToken[…].');
18
18
  const topicList = z.array(z.string().min(1).max(200).regex(/^[a-z0-9][a-z0-9:_-]*$/i, 'Use topic ids from GET /api/v1/push/topics.')).max(50);
@@ -155,20 +155,19 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
155
155
  const collections = new Set(Object.keys(CONTENT.collections));
156
156
  const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
157
157
  const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
158
- const changes = input.auto.eventChanges;
159
- const tags = changes?.tags ?? [];
160
158
  const known = await knownTopics(repo);
161
159
  const auto = {
162
160
  newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
163
161
  newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
164
162
  startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
165
163
  live: { enabled: input.auto.live?.enabled === true },
166
- eventChanges: CALENDAR.enabled ? {
167
- calendars: cals(changes?.calendars ?? []),
164
+ // a rule left without calendars is off: dropped
165
+ eventChanges: CALENDAR.enabled ? (input.auto.eventChanges ?? []).map((r) => ({
166
+ calendars: cals(r.calendars),
168
167
  // one of each tag, whatever its case (matching ignores case too)
169
- tags: tags.filter((t, i) => tags.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i),
170
- topics: [...new Set(changes?.topics ?? [])].filter((t) => known.has(t)),
171
- } : { calendars: [], tags: [], topics: [] },
168
+ tags: r.tags.filter((t, i) => r.tags.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i),
169
+ topics: [...new Set(r.topics)].filter((t) => known.has(t)),
170
+ })).filter((r) => r.calendars.length).slice(0, MAX_CHANGE_RULES) : [],
172
171
  };
173
172
  await repo.updateSettings({ push: { auto } });
174
173
  return { auto };
@@ -15,7 +15,7 @@ import { ANNOUNCEMENTS } from '../announcements/config';
15
15
  import type { LiveSnapshot } from '../live/types';
16
16
  import { PUSH } from './config';
17
17
  import { expoOptions, fetchReceipts, isGone, sendMessages, type ExpoMessage, type ExpoOptions } from './expo';
18
- import { EventSnapshot, type PushAuto, type PushNotification, type PushSource, type PushTopic } from './types';
18
+ import { EventSnapshot, type ChangeRule, type PushNotification, type PushSource, type PushTopic } from './types';
19
19
 
20
20
  const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
21
21
  /** automatic notices queued longer ago than this are skipped, never sent late */
@@ -57,9 +57,9 @@ export async function pushTopics(repo: ContentRepository, locale?: string | null
57
57
  const out: PushTopic[] = [{ id: 'news', label: w.news, kind: 'general' }];
58
58
  if (collections.length > 1) out.push(...collections.map(([key, c]): PushTopic => ({ id: `collection:${key}`, label: c.plural, kind: 'collection' })));
59
59
  if (CALENDAR.enabled) {
60
- const changes = settings.push.auto.eventChanges;
61
- // event-change notices go to `events` unless their rule names other topics
62
- out.push({ id: 'events', label: changes.calendars.length && !changes.topics.length ? w.eventsChanges : w.events, kind: 'general' });
60
+ // event-change notices go to `events` from a rule that names no other topics
61
+ const toEvents = settings.push.auto.eventChanges.some((r) => r.calendars.length && !r.topics.length);
62
+ out.push({ id: 'events', label: toEvents ? w.eventsChanges : w.events, kind: 'general' });
63
63
  const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
64
64
  if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
65
65
  }
@@ -224,20 +224,24 @@ export async function sendNotification(repo: ContentRepository, input: SendInput
224
224
 
225
225
  const listedCalendars = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
226
226
  const inRule = (rule: string[], e: CalEvent) => rule.includes('*') || e.calendarIds.some((id) => rule.includes(id));
227
- // the change rule: its calendars, and one of its tags when it lists some (any case)
228
- function inChangeRule(rule: PushAuto['eventChanges'], e: CalEvent) {
227
+ // a change rule: its calendars, and one of its tags when it lists some (any case)
228
+ function inChangeRule(rule: ChangeRule, e: CalEvent) {
229
229
  const tags = new Set(rule.tags.map((t) => t.toLowerCase()));
230
230
  return inRule(rule.calendars, e) && (!tags.size || e.tags.some((t) => tags.has(t.toLowerCase())));
231
231
  }
232
+ // every rule that covers the event adds its topics, or the event's own when it
233
+ // names none: in rule order, once each ([] = no rule covers it)
234
+ function changeAudience(rules: ChangeRule[], e: CalEvent, calendars: Map<string, Calendar>) {
235
+ return [...new Set(rules.filter((r) => inChangeRule(r, e)).flatMap((r) => (r.topics.length ? r.topics : eventTopics(e, calendars))))];
236
+ }
232
237
  /**
233
238
  * The topics this event's change notices go to (Admin → Mobile app → When an
234
- * event changes), or [] while that rule doesn't cover it. Apps show it to
235
- * promise "we'll let you know if it changes" only when it's true.
239
+ * event changes), or [] while no rule covers it. Apps show it to promise
240
+ * "we'll let you know if it changes" only when it's true.
236
241
  */
237
242
  export function changeTopics(e: CalEvent, settings: Settings, calendars: Map<string, Calendar>): string[] {
238
- const rule = settings.push.auto.eventChanges;
239
- if (!PUSH.enabled || !CALENDAR.enabled || !inChangeRule(rule, e)) return [];
240
- return rule.topics.length ? [...rule.topics] : eventTopics(e, calendars);
243
+ if (!PUSH.enabled || !CALENDAR.enabled) return [];
244
+ return changeAudience(settings.push.auto.eventChanges, e, calendars);
241
245
  }
242
246
  function eventTopics(e: CalEvent, calendars: Map<string, Calendar>) {
243
247
  return ['events', ...e.calendarIds.map((id) => calendars.get(id)).filter((c): c is Calendar => c?.visibility === 'public').map((c) => `calendar:${c.slug}`)];
@@ -355,7 +359,7 @@ export async function queueEventChange(repo: ContentRepository, before: CalEvent
355
359
  const was = eventSnapshot(before);
356
360
  if (JSON.stringify(was) === JSON.stringify(eventSnapshot(after))) return false;
357
361
  const [settings, all] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
358
- if (!inChangeRule(settings.push.auto.eventChanges, after) || !eventIsListed(after, listedCalendars(all))) return false;
362
+ if (!settings.push.auto.eventChanges.some((r) => inChangeRule(r, after)) || !eventIsListed(after, listedCalendars(all))) return false;
359
363
  const key = `change:${after.id}`;
360
364
  // a change to a date that starts soon waits only a minute (judged from what people last knew)
361
365
  const row = await repo.push.queuedChange(key);
@@ -381,7 +385,7 @@ async function announcedLately(repo: ContentRepository, e: CalEvent, origin: str
381
385
  return (await repo.announcements.pushedSince(now - HOUR)).some((a) => a.link !== '' && sitePath(a.link, origin) === page);
382
386
  }
383
387
 
384
- /** `topics`: the rule's own audience; [] = the event's topics. */
388
+ /** `topics`: the audience of the rules that cover it (changeTopics); [] = the event's topics. */
385
389
  export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Settings, calendars: Map<string, Calendar>, topics: string[] = []): SendInput {
386
390
  const tz = eventTimeZone(e, settings);
387
391
  const siteTz = settings.timezone || 'UTC';
@@ -448,9 +452,10 @@ export async function processPushQueue(repo: ContentRepository, opts: PushRunOpt
448
452
  // compared at send time: edits that cancel out, or come too late, send nothing
449
453
  const e = await repo.getEvent(row.docId);
450
454
  const was = readSnapshot(row.payload);
451
- const rule = auto.eventChanges;
452
- const change = e && was && eventIsListed(e, calendars) && inChangeRule(rule, e) ? eventChange(was, e, settings, now) : null;
453
- if (e && change && !(await announcedLately(repo, e, run.origin, now))) notice = eventChangeNotice(e, change, settings, calendars, rule.topics);
455
+ // one notice, to everyone the rules that cover it send to
456
+ const topics = e ? changeAudience(auto.eventChanges, e, calendars) : [];
457
+ const change = e && was && topics.length && eventIsListed(e, calendars) ? eventChange(was, e, settings, now) : null;
458
+ if (e && change && !(await announcedLately(repo, e, run.origin, now))) notice = eventChangeNotice(e, change, settings, calendars, topics);
454
459
  }
455
460
  if (!notice) { await repo.push.markQueue(row.key, 'skipped', null, now); continue; }
456
461
  const n = await sendNotification(repo, notice, run);
@@ -2,6 +2,22 @@ import { z } from 'zod';
2
2
 
3
3
  // '*' in a calendars list = every public event
4
4
  const keys = z.array(z.string().min(1).max(200)).max(100);
5
+ /** One "when an event changes" rule: the events it covers and who hears. */
6
+ export const ChangeRule = z.object({
7
+ calendars: keys.default([]),
8
+ /** only events with one of these tags (any case); [] = every event in the calendars */
9
+ tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
10
+ /** sent to these topics instead of the event's own (`events` and its calendars) */
11
+ topics: z.array(z.string().min(1).max(200)).max(10).default([]),
12
+ });
13
+ export type ChangeRule = z.infer<typeof ChangeRule>;
14
+ export const MAX_CHANGE_RULES = 10;
15
+ // one rule, as saved before there could be several: a list of one, or none while it had no calendars
16
+ const oneRule = (v: unknown) => {
17
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return v;
18
+ const calendars = (v as { calendars?: unknown }).calendars;
19
+ return Array.isArray(calendars) && calendars.length ? [v] : [];
20
+ };
5
21
  export const PushAuto = z.object({
6
22
  /** collection keys whose first publish sends a notification */
7
23
  newPost: z.object({ collections: keys.default([]) }).default({}),
@@ -11,14 +27,8 @@ export const PushAuto = z.object({
11
27
  startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
12
28
  /** "We're live": once per broadcast that live video detects, to devices on the `live` topic */
13
29
  live: z.object({ enabled: z.boolean().default(false) }).default({}),
14
- /** a published event's new date, time or place, or a cancellation, once its edits settle */
15
- eventChanges: z.object({
16
- calendars: keys.default([]),
17
- /** only events with one of these tags (any case); [] = every event in the calendars */
18
- tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
19
- /** sent to these topics instead of the event's own (`events` and its calendars) */
20
- topics: z.array(z.string().min(1).max(200)).max(10).default([]),
21
- }).default({}),
30
+ /** a published event's new date, time or place, or a cancellation, once its edits settle: one notice when any rule covers it */
31
+ eventChanges: z.preprocess(oneRule, z.array(ChangeRule).max(MAX_CHANGE_RULES)).default([]),
22
32
  });
23
33
  export type PushAuto = z.infer<typeof PushAuto>;
24
34
  export const PushSettings = z.object({ auto: PushAuto.default({}) });
@@ -1225,6 +1225,9 @@ export class SqlRepository implements ContentRepository {
1225
1225
  write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')}
1226
1226
  WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN ('pending','confirmed')) + ? <= ?)`,
1227
1227
  args: [...ins.args!,cap,b.eventId,b.start,b.seats,cap] };
1228
+ // same statement, so two at once from one address can't both get in
1229
+ if (guard.oncePerEmail && holds && b.email) write = { sql: write.sql + ` AND NOT EXISTS (SELECT 1 FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND email=? AND status IN ('pending','confirmed'))`,
1230
+ args: [...write.args!,b.eventId,b.start,b.email] };
1228
1231
  } else if (holds && b.resourceId) {
1229
1232
  write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE NOT EXISTS (
1230
1233
  SELECT 1 FROM bookings WHERE resource_id=? AND kind IN ('booking','block') AND status IN ('pending','confirmed') AND hold_start < ? AND hold_end > ?)`,