@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.
- package/CHANGELOG.md +18 -0
- package/docs/mobile.md +1 -1
- package/integration.mjs +1 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +11 -7
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +11 -7
- package/package.json +1 -1
- package/src/actions/index.ts +2 -1
- package/src/components/EventPage.astro +14 -5
- package/src/components/MobileApp.tsx +44 -23
- package/src/components/editor/EventPanel.tsx +3 -1
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +55 -1
- package/src/components/editor/event/registration.ts +18 -1
- package/src/lib/booking/emails.ts +37 -11
- package/src/lib/booking/service.ts +96 -19
- package/src/lib/calendar/api.ts +3 -2
- package/src/lib/calendar/events.ts +11 -0
- package/src/lib/calendar/members-config.ts +5 -0
- package/src/lib/calendar/requests.ts +4 -4
- package/src/lib/calendar/rsvp-words.ts +31 -2
- package/src/lib/content-service.ts +29 -14
- package/src/lib/drafts.ts +2 -0
- package/src/lib/editor/documents.ts +4 -1
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +66 -1
- package/src/lib/email/service.ts +1 -1
- package/src/lib/email/store.ts +17 -1
- package/src/lib/email/types.ts +3 -1
- package/src/lib/legal/text-en.ts +1 -1
- package/src/lib/legal/text-es.ts +1 -1
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/calendar.ts +23 -1
- package/src/lib/mobile/http.ts +1 -1
- package/src/lib/mobile/manage.ts +1 -1
- package/src/lib/mobile/openapi.ts +3 -2
- package/src/lib/mobile/rsvp.ts +14 -8
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/push/api.ts +7 -8
- package/src/lib/push/service.ts +21 -16
- package/src/lib/push/types.ts +18 -8
- package/src/lib/repo/sql.ts +7 -3
- package/src/lib/repo/types.ts +8 -4
- package/src/lib/schema.ts +8 -1
- package/src/pages/api/v1/events/[slug]/index.ts +8 -0
- package/src/pages/events/[slug].astro +10 -3
- package/src/styles/editor.css +4 -0
- package/src/styles/mobile-app.css +7 -0
- package/src/styles/site.css +3 -0
- 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.',
|
package/src/lib/email/service.ts
CHANGED
|
@@ -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') {
|
package/src/lib/email/store.ts
CHANGED
|
@@ -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> {
|
package/src/lib/email/types.ts
CHANGED
|
@@ -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 }
|
package/src/lib/legal/text-en.ts
CHANGED
|
@@ -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: [
|
package/src/lib/legal/text-es.ts
CHANGED
|
@@ -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);
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -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) } : {}),
|
package/src/lib/mobile/manage.ts
CHANGED
|
@@ -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,
|
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
}) });
|
package/src/lib/push/api.ts
CHANGED
|
@@ -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
|
-
|
|
167
|
-
|
|
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(
|
|
171
|
-
}
|
|
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 };
|
package/src/lib/push/service.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
out.push({ id: 'events', label:
|
|
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
|
-
//
|
|
228
|
-
function inChangeRule(rule:
|
|
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
|
|
235
|
-
*
|
|
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
|
-
|
|
239
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
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);
|