@acms-subir/core 0.3.38 → 0.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/docs/mobile.md +1 -1
  3. package/integration.mjs +1 -0
  4. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +11 -7
  5. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +11 -7
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +2 -1
  8. package/src/components/EventPage.astro +14 -5
  9. package/src/components/MobileApp.tsx +44 -23
  10. package/src/components/editor/EventPanel.tsx +3 -1
  11. package/src/components/editor/PostEditor.tsx +1 -1
  12. package/src/components/editor/event/RegistrationCard.tsx +55 -1
  13. package/src/components/editor/event/registration.ts +18 -1
  14. package/src/lib/booking/emails.ts +37 -11
  15. package/src/lib/booking/service.ts +96 -19
  16. package/src/lib/calendar/api.ts +3 -2
  17. package/src/lib/calendar/events.ts +11 -0
  18. package/src/lib/calendar/members-config.ts +5 -0
  19. package/src/lib/calendar/requests.ts +4 -4
  20. package/src/lib/calendar/rsvp-words.ts +31 -2
  21. package/src/lib/content-service.ts +29 -14
  22. package/src/lib/drafts.ts +2 -0
  23. package/src/lib/editor/documents.ts +4 -1
  24. package/src/lib/editor/schemas.ts +1 -1
  25. package/src/lib/email/booking-words.ts +66 -1
  26. package/src/lib/email/service.ts +1 -1
  27. package/src/lib/email/store.ts +17 -1
  28. package/src/lib/email/types.ts +3 -1
  29. package/src/lib/legal/text-en.ts +1 -1
  30. package/src/lib/legal/text-es.ts +1 -1
  31. package/src/lib/mobile/bookings.ts +1 -1
  32. package/src/lib/mobile/calendar.ts +23 -1
  33. package/src/lib/mobile/http.ts +1 -1
  34. package/src/lib/mobile/manage.ts +1 -1
  35. package/src/lib/mobile/openapi.ts +3 -2
  36. package/src/lib/mobile/rsvp.ts +14 -8
  37. package/src/lib/mobile/schemas.ts +7 -3
  38. package/src/lib/push/api.ts +7 -8
  39. package/src/lib/push/service.ts +21 -16
  40. package/src/lib/push/types.ts +18 -8
  41. package/src/lib/repo/sql.ts +7 -3
  42. package/src/lib/repo/types.ts +8 -4
  43. package/src/lib/schema.ts +8 -1
  44. package/src/pages/api/v1/events/[slug]/index.ts +8 -0
  45. package/src/pages/events/[slug].astro +10 -3
  46. package/src/styles/editor.css +4 -0
  47. package/src/styles/mobile-app.css +7 -0
  48. package/src/styles/site.css +3 -0
  49. package/src/theme-sdk/index.ts +1 -1
@@ -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 {
@@ -25,6 +25,22 @@ export interface BookingStrings {
25
25
  /** a pending booking nobody answered in time, declined automatically */
26
26
  lateDecline: string;
27
27
  staff: { pending: string; booked: string; review: string; open: string; pendingSubject: string; bookedSubject: string };
28
+ /**
29
+ * the hosts' email about an RSVP (or the contact address's, for an event
30
+ * without hosts): a new one, or one the person cancelled. {seats}, {count}
31
+ * and {capacity} are numbers, {date} the date's short form ("Fri Nov 7");
32
+ * the footer is `via` · `hosts` or `noHosts`.
33
+ */
34
+ rsvpStaff: {
35
+ subject: string; cancelledSubject: string; heading: string; cancelledHeading: string;
36
+ name: string; email: string; phone: string; answers: string; now: string;
37
+ confirmed: string; pending: string; seatFreed: string; seatsFreed: string;
38
+ seatTaken: string; seatsTaken: string; seatsTakenOf: string;
39
+ via: Record<'web' | 'app' | 'staff', string>;
40
+ hosts: string; noHosts: string;
41
+ /** the button for a host without Manage bookings: their My events page */
42
+ openMine: string;
43
+ };
28
44
  /** a room booking's own page: what differs from an RSVP's (rsvp-words.ts) */
29
45
  page: {
30
46
  untitled: string;
@@ -54,11 +70,20 @@ const STRINGS: Record<string, BookingStrings> = {
54
70
  updated: { subject: 'Updated: {title}', heading: 'Booking details updated', intro: 'Please review the latest details. Use the attached calendar file to update your calendar.' },
55
71
  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
72
  reminder: { subject: 'Reminder: {title} — {when}', heading: 'Coming up soon', intro: 'A quick reminder about your booking.' },
73
+ 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
74
  },
58
75
  manage: 'View or change your booking', rsvp: { manage: 'View or change your booking' },
59
76
  what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
60
77
  lateDecline: 'We weren’t able to confirm this booking in time. Please pick another time.',
61
78
  staff: { pending: 'New request to approve', booked: 'New booking', review: 'Review in the admin', open: 'Open in the admin', pendingSubject: 'Approve: {title} — {when}', bookedSubject: 'New booking: {title} — {when}' },
79
+ rsvpStaff: {
80
+ subject: 'New RSVP: {title}, {date}', cancelledSubject: 'RSVP cancelled: {title}, {date}', heading: 'New RSVP for {title}', cancelledHeading: 'RSVP cancelled for {title}',
81
+ name: 'Name', email: 'Email', phone: 'Phone', answers: 'Answers', now: 'Now',
82
+ confirmed: '{seats} · Confirmed', pending: '{seats} · Waiting for your approval', seatFreed: '{seats} freed', seatsFreed: '{seats} freed',
83
+ seatTaken: '{count} seat taken', seatsTaken: '{count} seats taken', seatsTakenOf: '{count} of {capacity} seats taken',
84
+ via: { web: 'Sent from the website', app: 'Sent from the app', staff: 'Added by staff' }, openMine: 'Open in My events',
85
+ hosts: 'You get these because you host this event.', noHosts: 'You get these because no one hosts this event.',
86
+ },
62
87
  page: {
63
88
  untitled: 'Booking', booked: 'You’re booked', yours: 'Your booking', moved: 'Booking moved', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
64
89
  declined: 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.', cancelledText: 'This booking is cancelled.',
@@ -77,6 +102,7 @@ const STRINGS: Record<string, BookingStrings> = {
77
102
  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
103
  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
104
  reminder: { subject: 'Recordatorio: {title} — {when}', heading: 'Ya casi es la fecha', intro: 'Un breve recordatorio de su reservación.' },
105
+ 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
106
  },
81
107
  manage: 'Ver o cambiar su reservación',
82
108
  rsvp: {
@@ -86,10 +112,19 @@ const STRINGS: Record<string, BookingStrings> = {
86
112
  cancelled: { subject: 'Cancelado: {title}', heading: 'Confirmación cancelada', intro: 'Esta confirmación se canceló.' },
87
113
  updated: { subject: 'Actualizado: {title}', heading: 'Cambiaron los detalles' },
88
114
  reminder: { intro: 'Un breve recordatorio: tiene un lugar apartado.' },
115
+ 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
116
  },
90
117
  what: 'Qué', when: 'Cuándo', where: 'Dónde', seats: 'Lugares', reference: 'Referencia {ref}', icsManage: 'Ver o cambiar: {url}',
91
118
  lateDecline: 'No pudimos confirmar esta reservación a tiempo. Por favor, elija otro horario.',
92
119
  staff: { pending: 'Nueva solicitud por aprobar', booked: 'Nueva reservación', review: 'Revisar en el panel', open: 'Abrir en el panel', pendingSubject: 'Por aprobar: {title} — {when}', bookedSubject: 'Nueva reservación: {title} — {when}' },
120
+ rsvpStaff: {
121
+ subject: 'Nueva confirmación: {title}, {date}', cancelledSubject: 'Confirmación cancelada: {title}, {date}', heading: 'Nueva confirmación para {title}', cancelledHeading: 'Confirmación cancelada para {title}',
122
+ name: 'Nombre', email: 'Correo', phone: 'Teléfono', answers: 'Respuestas', now: 'Ahora',
123
+ confirmed: '{seats} · Confirmado', pending: '{seats} · Esperando su aprobación', seatFreed: '{seats} liberado', seatsFreed: '{seats} liberados',
124
+ seatTaken: '{count} lugar ocupado', seatsTaken: '{count} lugares ocupados', seatsTakenOf: '{count} de {capacity} lugares ocupados',
125
+ via: { web: 'Enviado desde el sitio web', app: 'Enviado desde la app', staff: 'Agregado por el equipo' }, openMine: 'Abrir en Mis eventos',
126
+ hosts: 'Recibe estos correos porque usted organiza este evento.', noHosts: 'Recibe estos correos porque este evento no tiene organizadores.',
127
+ },
93
128
  page: {
94
129
  untitled: 'Reservación', booked: 'Tu reservación está confirmada', yours: 'Tu reservación', moved: 'Tu reservación cambió de horario', notAvailable: 'No disponible', cancelledTitle: 'Reservación cancelada',
95
130
  declined: 'Lo sentimos, no se pudo confirmar este horario. Puedes elegir otro.', cancelledText: 'Esta reservación se canceló.',
@@ -108,6 +143,7 @@ const STRINGS: Record<string, BookingStrings> = {
108
143
  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
144
  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
145
  reminder: { subject: 'Rappel : {title} — {when}', heading: 'C’est bientôt', intro: 'Un petit rappel de votre réservation.' },
146
+ 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
147
  },
112
148
  manage: 'Voir ou modifier votre réservation',
113
149
  rsvp: {
@@ -117,10 +153,19 @@ const STRINGS: Record<string, BookingStrings> = {
117
153
  cancelled: { heading: 'Inscription annulée', intro: 'Cette inscription a été annulée.' },
118
154
  updated: { heading: 'Détails mis à jour' },
119
155
  reminder: { intro: 'Un petit rappel de votre inscription.' },
156
+ 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
157
  },
121
158
  what: 'Quoi', when: 'Quand', where: 'Où', seats: 'Places', reference: 'Référence {ref}', icsManage: 'Gérer : {url}',
122
159
  lateDecline: 'Nous n’avons pas pu confirmer cette réservation à temps. Veuillez choisir un autre horaire.',
123
160
  staff: { pending: 'Nouvelle demande à valider', booked: 'Nouvelle réservation', review: 'Examiner dans l’administration', open: 'Ouvrir dans l’administration', pendingSubject: 'À valider : {title} — {when}', bookedSubject: 'Nouvelle réservation : {title} — {when}' },
161
+ rsvpStaff: {
162
+ subject: 'Nouvelle inscription : {title}, {date}', cancelledSubject: 'Inscription annulée : {title}, {date}', heading: 'Nouvelle inscription à {title}', cancelledHeading: 'Inscription à {title} annulée',
163
+ name: 'Nom', email: 'E-mail', phone: 'Téléphone', answers: 'Réponses', now: 'Maintenant',
164
+ confirmed: '{seats} · Confirmée', pending: '{seats} · En attente de votre validation', seatFreed: '{seats} libérée', seatsFreed: '{seats} libérées',
165
+ seatTaken: '{count} place prise', seatsTaken: '{count} places prises', seatsTakenOf: '{count} sur {capacity} places prises',
166
+ via: { web: 'Envoyée depuis le site', app: 'Envoyée depuis l’app', staff: 'Ajoutée par l’équipe' }, openMine: 'Ouvrir dans Mes événements',
167
+ hosts: 'Vous recevez ces e-mails parce que vous organisez cet événement.', noHosts: 'Vous recevez ces e-mails parce que cet événement n’a pas d’organisateur.',
168
+ },
124
169
  page: {
125
170
  untitled: 'Réservation', booked: 'Votre réservation est confirmée', yours: 'Votre réservation', moved: 'Réservation déplacée', notAvailable: 'Indisponible', cancelledTitle: 'Réservation annulée',
126
171
  declined: 'Désolés, ce créneau n’a pas pu être confirmé. N’hésitez pas à en choisir un autre.', cancelledText: 'Cette réservation est annulée.',
@@ -139,6 +184,7 @@ const STRINGS: Record<string, BookingStrings> = {
139
184
  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
185
  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
186
  reminder: { subject: 'Erinnerung: {title} — {when}', heading: 'Bald ist es so weit', intro: 'Eine kurze Erinnerung an Ihre Buchung.' },
187
+ 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
188
  },
143
189
  manage: 'Buchung ansehen oder ändern',
144
190
  rsvp: {
@@ -148,10 +194,19 @@ const STRINGS: Record<string, BookingStrings> = {
148
194
  cancelled: { heading: 'Anmeldung storniert', intro: 'Diese Anmeldung wurde storniert.' },
149
195
  updated: { heading: 'Details aktualisiert' },
150
196
  reminder: { intro: 'Eine kurze Erinnerung an Ihre Anmeldung.' },
197
+ 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
198
  },
152
199
  what: 'Was', when: 'Wann', where: 'Wo', seats: 'Plätze', reference: 'Referenz {ref}', icsManage: 'Verwalten: {url}',
153
200
  lateDecline: 'Wir konnten diese Buchung nicht rechtzeitig bestätigen. Bitte wählen Sie einen anderen Termin.',
154
201
  staff: { pending: 'Neue Anfrage zur Freigabe', booked: 'Neue Buchung', review: 'In der Verwaltung prüfen', open: 'In der Verwaltung öffnen', pendingSubject: 'Freigeben: {title} — {when}', bookedSubject: 'Neue Buchung: {title} — {when}' },
202
+ rsvpStaff: {
203
+ subject: 'Neue Anmeldung: {title}, {date}', cancelledSubject: 'Anmeldung storniert: {title}, {date}', heading: 'Neue Anmeldung für {title}', cancelledHeading: 'Anmeldung für {title} storniert',
204
+ name: 'Name', email: 'E-Mail', phone: 'Telefon', answers: 'Antworten', now: 'Jetzt',
205
+ confirmed: '{seats} · Bestätigt', pending: '{seats} · Wartet auf Ihre Freigabe', seatFreed: '{seats} frei geworden', seatsFreed: '{seats} frei geworden',
206
+ seatTaken: '{count} Platz belegt', seatsTaken: '{count} Plätze belegt', seatsTakenOf: '{count} von {capacity} Plätzen belegt',
207
+ via: { web: 'Über die Website gesendet', app: 'Über die App gesendet', staff: 'Vom Team eingetragen' }, openMine: 'In Meine Veranstaltungen öffnen',
208
+ hosts: 'Sie erhalten diese E-Mails, weil Sie diese Veranstaltung betreuen.', noHosts: 'Sie erhalten diese E-Mails, weil niemand diese Veranstaltung betreut.',
209
+ },
155
210
  page: {
156
211
  untitled: 'Buchung', booked: 'Ihre Buchung ist bestätigt', yours: 'Ihre Buchung', moved: 'Buchung verschoben', notAvailable: 'Nicht verfügbar', cancelledTitle: 'Buchung storniert',
157
212
  declined: 'Leider konnte dieser Termin nicht bestätigt werden. Wählen Sie gern einen anderen.', cancelledText: 'Diese Buchung ist storniert.',
@@ -170,6 +225,7 @@ const STRINGS: Record<string, BookingStrings> = {
170
225
  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
226
  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
227
  reminder: { subject: 'Lembrete: {title} — {when}', heading: 'Está chegando', intro: 'Um lembrete rápido sobre sua reserva.' },
228
+ 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
229
  },
174
230
  manage: 'Ver ou alterar sua reserva',
175
231
  rsvp: {
@@ -179,10 +235,19 @@ const STRINGS: Record<string, BookingStrings> = {
179
235
  cancelled: { heading: 'Inscrição cancelada', intro: 'Esta inscrição foi cancelada.' },
180
236
  updated: { heading: 'Detalhes atualizados' },
181
237
  reminder: { intro: 'Um lembrete rápido sobre sua inscrição.' },
238
+ 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
239
  },
183
240
  what: 'O quê', when: 'Quando', where: 'Onde', seats: 'Vagas', reference: 'Referência {ref}', icsManage: 'Gerenciar: {url}',
184
241
  lateDecline: 'Não conseguimos confirmar esta reserva a tempo. Por favor, escolha outro horário.',
185
242
  staff: { pending: 'Novo pedido para aprovar', booked: 'Nova reserva', review: 'Revisar no painel', open: 'Abrir no painel', pendingSubject: 'Aprovar: {title} — {when}', bookedSubject: 'Nova reserva: {title} — {when}' },
243
+ rsvpStaff: {
244
+ subject: 'Nova inscrição: {title}, {date}', cancelledSubject: 'Inscrição cancelada: {title}, {date}', heading: 'Nova inscrição em {title}', cancelledHeading: 'Inscrição cancelada em {title}',
245
+ name: 'Nome', email: 'E-mail', phone: 'Telefone', answers: 'Respostas', now: 'Agora',
246
+ confirmed: '{seats} · Confirmada', pending: '{seats} · Aguardando sua aprovação', seatFreed: '{seats} liberada', seatsFreed: '{seats} liberadas',
247
+ seatTaken: '{count} vaga ocupada', seatsTaken: '{count} vagas ocupadas', seatsTakenOf: '{count} de {capacity} vagas ocupadas',
248
+ via: { web: 'Enviada pelo site', app: 'Enviada pelo app', staff: 'Adicionada pela equipe' }, openMine: 'Abrir em Meus eventos',
249
+ hosts: 'Você recebe estes e-mails porque organiza este evento.', noHosts: 'Você recebe estes e-mails porque este evento não tem organizadores.',
250
+ },
186
251
  page: {
187
252
  untitled: 'Reserva', booked: 'Sua reserva está confirmada', yours: 'Sua reserva', moved: 'Reserva remarcada', notAvailable: 'Indisponível', cancelledTitle: 'Reserva cancelada',
188
253
  declined: 'Desculpe, não foi possível confirmar este horário. Fique à vontade para escolher outro.', cancelledText: 'Esta reserva foi cancelada.',
@@ -22,7 +22,7 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
22
22
  const b = await repo.getBooking(payload.bookingId);
23
23
  if (!b || (b.notificationVersion ?? 0) !== payload.version) return null;
24
24
  if (payload.kind === 'reminder' && (b.status !== 'confirmed' || b.start <= Date.now())) return null;
25
- const mail = await bookingMail(repo, b, payload.kind, origin, from, payload);
25
+ const mail = await bookingMail(repo, b, payload.kind, origin, from, payload, job.recipient);
26
26
  return { ...mail, to: job.recipient };
27
27
  }
28
28
  if (job.kind === 'auth') {
@@ -1,5 +1,5 @@
1
1
  import type { SqlDriver, SqlStatement } from '../repo/sql';
2
- import type { ConfigRecord, EmailConfig, EmailJob, Invitation, NewEmailJob, SendResult } from './types';
2
+ import type { ConfigRecord, EmailConfig, EmailJob, Invitation, NewEmailJob, RsvpStaffNotice, SendResult } from './types';
3
3
  const record = (r: Record<string, unknown>): ConfigRecord => ({ version: Number(r.version), config: JSON.parse(String(r.config)), encrypted: String(r.encrypted), createdAt: Number(r.created_at) });
4
4
  function job(r: Record<string, unknown>): EmailJob {
5
5
  return { id: String(r.id), dedupe: String(r.dedupe), kind: String(r.kind), recipient: String(r.recipient), payload: String(r.payload), configVersion: Number(r.config_version), state: r.state as EmailJob['state'], attempts: Number(r.attempts), nextAt: Number(r.next_at), expiresAt: r.expires_at == null ? null : Number(r.expires_at), createdAt: Number(r.created_at), lease: String(r.lease), leaseUntil: r.lease_until == null ? null : Number(r.lease_until), provider: String(r.provider), providerId: String(r.provider_id), error: String(r.error), acceptedAt: r.accepted_at == null ? null : Number(r.accepted_at), updatedAt: Number(r.updated_at), fingerprint: String(r.fingerprint), rendered: String(r.rendered || '') };
@@ -25,6 +25,22 @@ export function bookingJobs(id: string, kind: string, version?: number, staff =
25
25
  });
26
26
  return [select('b.email', 'bookings b', kind), ...(staff ? [select('j.value', 'bookings b JOIN resources r ON r.id = b.resource_id, json_each(r.notify_emails) j', 'staff-new')] : [])];
27
27
  }
28
+ // Staff hear about an RSVP (new, or cancelled by the person) at the addresses
29
+ // the caller picked, in the same batch and only if the change landed.
30
+ // `taken` is the date's seat count right after it.
31
+ export function rsvpStaffJobs(id: string, kind: 'staff-rsvp' | 'staff-rsvp-cancelled', notice: RsvpStaffNotice | undefined, version: number, mutation = ''): SqlStatement[] {
32
+ if (!notice?.to.length) return [];
33
+ const now = Date.now();
34
+ return [{
35
+ sql: `INSERT OR IGNORE INTO email_jobs (id,dedupe,kind,recipient,payload,config_version,next_at,expires_at,created_at,updated_at)
36
+ SELECT lower(hex(randomblob(16))), b.id || ':' || b.notification_version || ':' || ? || ':' || lower(j.value), 'booking', lower(j.value),
37
+ json_object('bookingId',b.id,'version',b.notification_version,'kind',?,'why',?,'via',?,
38
+ 'taken',(SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=b.event_id AND start_at=b.start_at AND status IN ('pending','confirmed'))),
39
+ COALESCE((SELECT MAX(version) FROM email_configs),0), ?, NULL, ?, ?
40
+ FROM bookings b, json_each(?) j WHERE b.id = ? AND b.kind = 'rsvp' AND j.value != '' AND b.notification_version = ? ${mutation ? 'AND b.email_mutation = ?' : ''}`,
41
+ args: [kind, kind, notice.why, notice.via, now, now, now, JSON.stringify(notice.to), id, version, ...(mutation ? [mutation] : [])],
42
+ }];
43
+ }
28
44
  export class EmailStore {
29
45
  constructor(private getDriver: () => Promise<SqlDriver>) {}
30
46
  async config(version?: number): Promise<ConfigRecord | null> {
@@ -1,4 +1,4 @@
1
- import type { Role } from '../schema';
1
+ import type { Booking, Role } from '../schema';
2
2
  export type Provider = 'resend' | 'mailgun' | 'ses' | 'cloudflare';
3
3
  export type ProviderChoice = Provider | 'auto' | 'none';
4
4
  export interface Attachment { filename: string; type: string; content: string }
@@ -19,6 +19,8 @@ export interface EmailJob {
19
19
  acceptedAt: number | null; updatedAt: number; fingerprint: string;
20
20
  rendered: string;
21
21
  }
22
+ /** staff to tell about an RSVP (booking/service.ts rsvpStaff): to its hosts, else the contact address; via: where the person did it */
23
+ export interface RsvpStaffNotice { to: string[]; why: 'hosts' | 'contact'; via: Booking['source'] }
22
24
  export interface NewEmailJob { id?: string; dedupe: string; kind: string; recipient: string; payload: string; configVersion: number; nextAt?: number; expiresAt?: number }
23
25
  export type SendResult = { state: 'accepted'; providerId: string } | { state: 'retrying' | 'failed' | 'unknown'; error: string; retryAfter?: number };
24
26
  export interface Invitation { id: string; email: string; name: string; role: Role; state: string; expiresAt: number; createdAt: number; invitedBy: string }
@@ -42,7 +42,7 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
42
42
  ] },
43
43
  ];
44
44
  if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Bookings and RSVPs' : 'Event RSVPs', text: [
45
- `When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}.`,
45
+ `When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}. When you RSVP or cancel an RSVP, the event’s hosts (or our staff) get an email with your RSVP details.`,
46
46
  'Each booking has a private link to see, change or cancel it. Keep it private: anyone with the link can use it.',
47
47
  ] });
48
48
  s.push({ id: 'forms', title: 'Forms', text: [
@@ -43,7 +43,7 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
43
43
  ] },
44
44
  ];
45
45
  if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Reservaciones y registros' : 'Registros a eventos', text: [
46
- `Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}.`,
46
+ `Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}. Cuando se registra a un evento o cancela su registro, los organizadores del evento (o nuestro personal) reciben un correo con los datos de su registro.`,
47
47
  'Cada reservación tiene un enlace privado para verla, cambiarla o cancelarla. Manténgalo en privado: cualquiera que lo tenga puede usarlo.',
48
48
  ] });
49
49
  s.push({ id: 'forms', title: 'Formularios', text: [
@@ -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);
@@ -4,7 +4,7 @@ import { publicTags, tagFilter, type BookingLink, type CalEvent, type Calendar,
4
4
  import { ApiError } from '../api';
5
5
  import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
6
6
  import { calendarToJson, eventToJson } from '../calendar/api';
7
- import { eventIsListed, eventIsPublic, expandEvents } from '../calendar/events';
7
+ import { eventIsListed, eventIsPublic, expandEvents, featuredOccurrence } from '../calendar/events';
8
8
  import { availabilityContext } from '../booking/service';
9
9
  import { addDaysYmd, describeLink, linkLengths, localMidnight, slotsForDay, ymd } from '../booking/availability';
10
10
  import { siteOrigin } from '../seo';
@@ -65,6 +65,28 @@ export async function listEvents(repo: ContentRepository, url: URL) {
65
65
  pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
66
66
  };
67
67
  }
68
+ // one event by slug (or id), whatever its date: apps open it from a link, a
69
+ // push or an announcement, often weeks ahead. `on` ('YYYY-MM-DD', like the
70
+ // website's ?on=) picks that day's occurrence; otherwise the next one, or the
71
+ // most recent when it's over.
72
+ export const eventDetailQuery = z.object({ locale: localeQuery, on: isoDay.optional() }).strict();
73
+ export async function getEventDetail(repo: ContentRepository, url: URL, ref: string) {
74
+ requireCalendar();
75
+ const q = eventDetailQuery.parse(queryParams(url));
76
+ const locale = mobileLocale(q.locale);
77
+ const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
78
+ const found = ref && ref.length <= 200 ? await repo.getEventBySlug(ref) ?? await repo.getEvent(ref) : null;
79
+ const all = new Map(calendars.map(c => [c.id, c]));
80
+ if (!found || !eventIsPublic(found, all)) throw new ApiError(404, 'Event not found.');
81
+ const e = localizeDoc(found, locale ?? undefined);
82
+ const o = featuredOccurrence(e, settings, Date.now(), q.on);
83
+ const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public').map(c => [c.id, c]));
84
+ return {
85
+ locale,
86
+ occurrence: o ? { id: `${e.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, cancelled: o.cancelled } : null,
87
+ event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale),
88
+ };
89
+ }
68
90
  // e is already localized; publicCalendars limits which calendars are named
69
91
  export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null) {
70
92
  const event = eventToJson(e, origin, publicCalendars, settings.timezone);
@@ -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) } : {}),
@@ -75,7 +75,7 @@ export const getManaged = async (c: PublicContext) => view(await managed(c));
75
75
 
76
76
  export async function cancelManaged(c: PublicContext) {
77
77
  const m = await managed(c);
78
- return view(m, await guestCancel(c.repo, m.booking, flow(m, c)));
78
+ return view(m, await guestCancel(c.repo, m.booking, flow(m, c), 'app'));
79
79
  }
80
80
 
81
81
  export const manageAvailabilityQuery = z.object({
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { zodToJsonSchema } from 'zod-to-json-schema';
3
3
  import type { ContentRepository } from '../repo/types';
4
4
  import { contentQuery, contentDetailQuery } from './content';
5
- import { eventsQuery, availabilityQuery } from './calendar';
5
+ import { eventsQuery, eventDetailQuery, availabilityQuery } from './calendar';
6
6
  import { bookingInput } from './bookings';
7
7
  import { rsvpQuery } from './rsvp';
8
8
  import { pagesQuery } from './pages';
@@ -51,6 +51,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
51
51
  ['/content/{id}', 'Published content detail', contentDetailQuery, ref('Content')],
52
52
  ['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
53
53
  ['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
54
+ ['/events/{slug}', 'One public event by slug or id, whatever its date, with its next occurrence (or the one on `on`, YYYY-MM-DD; the most recent once it is over); requires calendar', eventDetailQuery, ref('EventDetail')],
54
55
  ['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
55
56
  ['/events/{slug}/rsvp', 'RSVP status of one date (start = the occurrence start exactly): open or why not, live seats, the questions and their version; never cached; requires calendar', rsvpQuery, ref('RsvpStatus'), false],
56
57
  ['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
@@ -79,7 +80,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
79
80
  paths['/api/v1/bookings'] = {
80
81
  post: {
81
82
  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.',
83
+ 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
84
  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
85
  requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
85
86
  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,
@@ -30,7 +30,7 @@ export const Page = ContentSummary.extend({
30
30
  export const Event = CalEvent.pick({
31
31
  id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
32
32
  allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
33
- }).extend({ rsvp: CalEvent.shape.rsvp.optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
33
+ }).extend({ rsvp: CalEvent.shape.rsvp.removeDefault().omit({ notifyStaff: true }).default({}).optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
34
34
  calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
35
35
  // push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
36
36
  notify: z.object({ changes: strings }),
@@ -40,6 +40,7 @@ export const Events = z.object({ locale, timezone: str, from: date, to: date, pa
40
40
  });
41
41
  const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
42
42
  const ContentHit = ContentSummary.extend({ snippet: Snippet });
43
+ export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event });
43
44
  export const SearchResults = z.object({ locale, query: str,
44
45
  posts: z.array(ContentHit), pages: z.array(ContentHit),
45
46
  events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event })),
@@ -72,8 +73,10 @@ export const Form = z.object({
72
73
  export const FormReceipt = z.object({ received: z.literal(true), confirmation: str });
73
74
  export const RsvpStatus = z.object({
74
75
  event: z.object({ id: str, slug: str, title: str }), start: date, end: date,
75
- open: bool, reason: z.enum(['disabled', 'ended', 'cancelled', 'full']).nullable(),
76
+ open: bool, reason: z.enum(['disabled', 'ended', 'closed', 'cancelled', 'full']).nullable(),
76
77
  going: z.number().int(), left: z.number().int().nullable(), capacity: z.number().int(), maxSeats: z.number().int(), autoConfirm: bool,
78
+ // the event's RSVP deadline for this date (null: open until it ends)
79
+ closesAt: date.nullable(),
77
80
  form: Form.omit({ id: true }).nullable(),
78
81
  });
79
82
  const GuestChanges = z.object({ cancel: bool, reschedule: bool, cutoffHours: z.number().int(), closed: bool });
@@ -135,6 +138,7 @@ export const LiveStatus = z.object({
135
138
  display: z.object({ style: z.enum(['poster', 'split', 'row']), bar: bool, header: bool, dock: bool }),
136
139
  });
137
140
  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']),
141
+ // duplicate (409): that email already has an RSVP for the date
142
+ 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
143
  message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
140
144
  }) });
@@ -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);