@acms-subir/core 0.3.55 → 0.3.56
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 +16 -0
- package/docs/calendar.md +75 -2
- package/docs/mobile.md +3 -1
- package/docs/server.md +51 -0
- package/integration.mjs +34 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/editor/event/SingleDates.tsx +12 -2
- package/src/layouts/Admin.astro +7 -0
- package/src/lib/calendar/request-admin.ts +211 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- package/src/lib/email/service.ts +6 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +4 -0
- package/src/lib/legal/text-en.ts +3 -0
- package/src/lib/legal/text-es.ts +3 -0
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +14 -3
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/types.ts +1 -1
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +43 -2
- package/src/pages/admin/calendar/events/index.astro +2 -0
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +56 -1
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { RequestRefusal } from './request-service';
|
|
2
|
+
|
|
3
|
+
// Words of event requests (lib/calendar/request-service.ts): the built-in
|
|
4
|
+
// form at acms({ requests.path }), the emails to the person who asked (in
|
|
5
|
+
// the language they asked in) and the staff alert (in the site's main
|
|
6
|
+
// language). Plain strings with {time}, {email}, {phone}, {count}, {title}
|
|
7
|
+
// and {when} slots (live/words.ts `fill`). Spanish uses usted, like the
|
|
8
|
+
// other emails. The app's screen takes its EN/ES words from @acms-subir/native
|
|
9
|
+
// (useStrings), whose tests check them against these. Other languages fall
|
|
10
|
+
// back to English.
|
|
11
|
+
|
|
12
|
+
export interface RequestStrings {
|
|
13
|
+
// the form
|
|
14
|
+
heading: string;
|
|
15
|
+
/** when the site set no notice of its own */
|
|
16
|
+
notice: string;
|
|
17
|
+
somethingNew: string;
|
|
18
|
+
change: string;
|
|
19
|
+
what: string;
|
|
20
|
+
type: string;
|
|
21
|
+
date: string;
|
|
22
|
+
starts: string;
|
|
23
|
+
ends: string;
|
|
24
|
+
repeats: string;
|
|
25
|
+
once: string;
|
|
26
|
+
weekly: string;
|
|
27
|
+
other: string;
|
|
28
|
+
otherHint: string;
|
|
29
|
+
room: string;
|
|
30
|
+
notSure: string;
|
|
31
|
+
/** the live room check: "{room} looks free then." / "{room} isn’t free then." */
|
|
32
|
+
free: string;
|
|
33
|
+
taken: string;
|
|
34
|
+
people: string;
|
|
35
|
+
message: string;
|
|
36
|
+
optional: string;
|
|
37
|
+
event: string;
|
|
38
|
+
pickEvent: string;
|
|
39
|
+
onDate: string;
|
|
40
|
+
onDateHint: string;
|
|
41
|
+
whatChange: string;
|
|
42
|
+
contact: string;
|
|
43
|
+
name: string;
|
|
44
|
+
email: string;
|
|
45
|
+
phone: string;
|
|
46
|
+
contactWhy: string;
|
|
47
|
+
privacy: string;
|
|
48
|
+
send: string;
|
|
49
|
+
closed: string;
|
|
50
|
+
/** after `closed`, with the site's phone */
|
|
51
|
+
call: string;
|
|
52
|
+
// sent
|
|
53
|
+
sent: string;
|
|
54
|
+
/** with the site's reply time ("3 days") */
|
|
55
|
+
answerWithin: string;
|
|
56
|
+
answerBy: string;
|
|
57
|
+
yours: string;
|
|
58
|
+
notYet: string;
|
|
59
|
+
sooner: string;
|
|
60
|
+
another: string;
|
|
61
|
+
/** "about {count} people" */
|
|
62
|
+
aboutPeople: string;
|
|
63
|
+
/** "Change to {title}" */
|
|
64
|
+
changeTo: string;
|
|
65
|
+
everyDate: string;
|
|
66
|
+
errors: Record<RequestRefusal | 'failed', string>;
|
|
67
|
+
// the emails to the person
|
|
68
|
+
mail: {
|
|
69
|
+
receivedSubject: string;
|
|
70
|
+
received: string;
|
|
71
|
+
/** after `received` when the site gave a reply time */
|
|
72
|
+
within: string;
|
|
73
|
+
replySubject: string;
|
|
74
|
+
reply: string;
|
|
75
|
+
approvedSubject: string;
|
|
76
|
+
approved: string;
|
|
77
|
+
approvedChange: string;
|
|
78
|
+
declinedSubject: string;
|
|
79
|
+
declined: string;
|
|
80
|
+
declinedChange: string;
|
|
81
|
+
fromTeam: string;
|
|
82
|
+
notYou: string;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const STRINGS: Record<string, RequestStrings> = {
|
|
87
|
+
en: {
|
|
88
|
+
heading: 'Request an event', notice: 'We review every request. It isn’t on the calendar until we confirm it.',
|
|
89
|
+
somethingNew: 'Something new', change: 'Change to an event', what: 'What are you planning?', type: 'Kind', date: 'Date', starts: 'Starts', ends: 'Ends',
|
|
90
|
+
repeats: 'Repeats', once: 'Just once', weekly: 'Every week', other: 'Something else', otherHint: 'Tell us how it repeats',
|
|
91
|
+
room: 'Room', notSure: 'Not sure', free: '{room} looks free then.', taken: '{room} isn’t free then.', people: 'How many people?', message: 'Anything else we should know?', optional: 'Optional',
|
|
92
|
+
event: 'Which event?', pickEvent: 'Choose an event', onDate: 'Which date?', onDateHint: 'Leave it empty if it’s about every date.', whatChange: 'What should change?',
|
|
93
|
+
contact: 'How we reach you', name: 'Your name', email: 'Email', phone: 'Phone', contactWhy: 'We use this only to answer your request.', privacy: 'Privacy',
|
|
94
|
+
send: 'Send request', closed: 'We aren’t taking requests here right now.', call: 'Call us at {phone}.',
|
|
95
|
+
sent: 'Request sent', answerWithin: 'We usually answer within {time}, by email to {email}.', answerBy: 'We’ll answer by email to {email}.',
|
|
96
|
+
yours: 'Your request', notYet: 'It isn’t on the calendar until we confirm it.', sooner: 'Need it sooner? Call {phone}.', another: 'Send another request',
|
|
97
|
+
aboutPeople: 'about {count} people', changeTo: 'Change to {title}', everyDate: 'every date',
|
|
98
|
+
errors: {
|
|
99
|
+
closed: 'We aren’t taking requests here right now.', trap: 'Couldn’t send this request.', busy: 'Too many requests. Try again in a minute.',
|
|
100
|
+
full: 'We’ve had a lot of requests today. Please try again tomorrow.', changed: 'This request was already sent. Reload the page to send a new one.',
|
|
101
|
+
title: 'Say what you’re planning.', type: 'Choose a kind from the list.', start: 'Pick a date and a start time.', end: 'Pick an end time within a day of the start.',
|
|
102
|
+
past: 'Pick a time that hasn’t passed.', far: 'Pick a date within the next two years.', repeat: 'Tell us how it repeats.', room: 'Choose a room from the list.',
|
|
103
|
+
people: 'Check the number of people.', event: 'Choose an event from the list.', date: 'That event doesn’t happen on that date.', what: 'Tell us what should change.',
|
|
104
|
+
name: 'Add your name.', email: 'Add a valid email address.', phone: 'Check the phone number, or leave it empty.', failed: 'Couldn’t send your request. Please try again.',
|
|
105
|
+
},
|
|
106
|
+
mail: {
|
|
107
|
+
receivedSubject: 'We got your request', received: 'We got your event request. It isn’t on the calendar until we confirm it.', within: 'We usually answer within {time}.',
|
|
108
|
+
replySubject: 'About your event request', reply: 'We wrote about your request:',
|
|
109
|
+
approvedSubject: 'Your request was approved', approved: 'We approved your event request. We’ll be in touch if we need anything else.', approvedChange: 'We approved your request, and we’ll make the change.',
|
|
110
|
+
declinedSubject: 'About your event request', declined: 'We’re sorry, we can’t take your event request.', declinedChange: 'We’re sorry, we can’t make the change you asked for.',
|
|
111
|
+
fromTeam: 'From the team:', notYou: 'If you didn’t send this request, you can ignore this email.',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
es: {
|
|
115
|
+
heading: 'Solicitar un evento', notice: 'Revisamos cada solicitud. No estará en el calendario hasta que la confirmemos.',
|
|
116
|
+
somethingNew: 'Algo nuevo', change: 'Un cambio a un evento', what: '¿Qué está planeando?', type: 'Tipo', date: 'Fecha', starts: 'Comienza', ends: 'Termina',
|
|
117
|
+
repeats: 'Se repite', once: 'Solo una vez', weekly: 'Cada semana', other: 'Otra cosa', otherHint: 'Díganos cómo se repite',
|
|
118
|
+
room: 'Salón', notSure: 'No estoy seguro', free: '{room}: libre a esa hora.', taken: '{room}: no está libre a esa hora.', people: '¿Cuántas personas?', message: '¿Algo más que debamos saber?', optional: 'Opcional',
|
|
119
|
+
event: '¿Qué evento?', pickEvent: 'Elija un evento', onDate: '¿Qué fecha?', onDateHint: 'Déjelo vacío si es sobre todas las fechas.', whatChange: '¿Qué debe cambiar?',
|
|
120
|
+
contact: 'Cómo le contactamos', name: 'Su nombre', email: 'Correo electrónico', phone: 'Teléfono', contactWhy: 'Solo lo usamos para responder a su solicitud.', privacy: 'Privacidad',
|
|
121
|
+
send: 'Enviar solicitud', closed: 'Por ahora no recibimos solicitudes aquí.', call: 'Llámenos al {phone}.',
|
|
122
|
+
sent: 'Solicitud enviada', answerWithin: 'Normalmente respondemos en {time}, por correo a {email}.', answerBy: 'Le responderemos por correo a {email}.',
|
|
123
|
+
yours: 'Su solicitud', notYet: 'No estará en el calendario hasta que la confirmemos.', sooner: '¿La necesita antes? Llame al {phone}.', another: 'Enviar otra solicitud',
|
|
124
|
+
aboutPeople: 'unas {count} personas', changeTo: 'Cambio a {title}', everyDate: 'todas las fechas',
|
|
125
|
+
errors: {
|
|
126
|
+
closed: 'Por ahora no recibimos solicitudes aquí.', trap: 'No se pudo enviar esta solicitud.', busy: 'Demasiadas solicitudes. Inténtelo de nuevo en un minuto.',
|
|
127
|
+
full: 'Hoy hemos recibido muchas solicitudes. Inténtelo de nuevo mañana.', changed: 'Esta solicitud ya se envió. Vuelva a cargar la página para enviar otra.',
|
|
128
|
+
title: 'Diga qué está planeando.', type: 'Elija un tipo de la lista.', start: 'Elija una fecha y una hora de inicio.', end: 'Elija una hora de fin dentro de un día desde el inicio.',
|
|
129
|
+
past: 'Elija una hora que no haya pasado.', far: 'Elija una fecha dentro de los próximos dos años.', repeat: 'Díganos cómo se repite.', room: 'Elija un salón de la lista.',
|
|
130
|
+
people: 'Revise el número de personas.', event: 'Elija un evento de la lista.', date: 'Ese evento no ocurre en esa fecha.', what: 'Díganos qué debe cambiar.',
|
|
131
|
+
name: 'Escriba su nombre.', email: 'Escriba un correo electrónico válido.', phone: 'Revise el número de teléfono o déjelo vacío.', failed: 'No se pudo enviar su solicitud. Inténtelo de nuevo.',
|
|
132
|
+
},
|
|
133
|
+
mail: {
|
|
134
|
+
receivedSubject: 'Recibimos su solicitud', received: 'Recibimos su solicitud de evento. No estará en el calendario hasta que la confirmemos.', within: 'Normalmente respondemos en {time}.',
|
|
135
|
+
replySubject: 'Sobre su solicitud de evento', reply: 'Le escribimos sobre su solicitud:',
|
|
136
|
+
approvedSubject: 'Aprobamos su solicitud', approved: 'Aprobamos su solicitud de evento. Le escribiremos si necesitamos algo más.', approvedChange: 'Aprobamos su solicitud y haremos el cambio.',
|
|
137
|
+
declinedSubject: 'Sobre su solicitud de evento', declined: 'Lo sentimos, no podemos aceptar su solicitud de evento.', declinedChange: 'Lo sentimos, no podemos hacer el cambio que pidió.',
|
|
138
|
+
fromTeam: 'Del equipo:', notYou: 'Si usted no envió esta solicitud, puede ignorar este correo.',
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
fr: {
|
|
142
|
+
heading: 'Demander un événement', notice: 'Nous examinons chaque demande. Elle n’est pas au calendrier tant que nous ne l’avons pas confirmée.',
|
|
143
|
+
somethingNew: 'Quelque chose de nouveau', change: 'Un changement à un événement', what: 'Que prévoyez-vous ?', type: 'Type', date: 'Date', starts: 'Début', ends: 'Fin',
|
|
144
|
+
repeats: 'Se répète', once: 'Une seule fois', weekly: 'Chaque semaine', other: 'Autre chose', otherHint: 'Dites-nous comment cela se répète',
|
|
145
|
+
room: 'Salle', notSure: 'Je ne sais pas', free: '{room} : libre à cette heure-là.', taken: '{room} : pas libre à cette heure-là.', people: 'Combien de personnes ?', message: 'Autre chose à savoir ?', optional: 'Facultatif',
|
|
146
|
+
event: 'Quel événement ?', pickEvent: 'Choisissez un événement', onDate: 'Quelle date ?', onDateHint: 'Laissez vide si cela concerne toutes les dates.', whatChange: 'Que faut-il changer ?',
|
|
147
|
+
contact: 'Comment vous joindre', name: 'Votre nom', email: 'E-mail', phone: 'Téléphone', contactWhy: 'Nous l’utilisons uniquement pour répondre à votre demande.', privacy: 'Confidentialité',
|
|
148
|
+
send: 'Envoyer la demande', closed: 'Nous ne prenons pas de demandes ici pour le moment.', call: 'Appelez-nous au {phone}.',
|
|
149
|
+
sent: 'Demande envoyée', answerWithin: 'Nous répondons généralement sous {time}, par e-mail à {email}.', answerBy: 'Nous vous répondrons par e-mail à {email}.',
|
|
150
|
+
yours: 'Votre demande', notYet: 'Elle n’est pas au calendrier tant que nous ne l’avons pas confirmée.', sooner: 'Besoin d’une réponse plus tôt ? Appelez le {phone}.', another: 'Envoyer une autre demande',
|
|
151
|
+
aboutPeople: 'environ {count} personnes', changeTo: 'Changement : {title}', everyDate: 'toutes les dates',
|
|
152
|
+
errors: {
|
|
153
|
+
closed: 'Nous ne prenons pas de demandes ici pour le moment.', trap: 'Impossible d’envoyer cette demande.', busy: 'Trop de demandes. Réessayez dans une minute.',
|
|
154
|
+
full: 'Nous avons reçu beaucoup de demandes aujourd’hui. Réessayez demain.', changed: 'Cette demande a déjà été envoyée. Rechargez la page pour en envoyer une autre.',
|
|
155
|
+
title: 'Dites-nous ce que vous prévoyez.', type: 'Choisissez un type dans la liste.', start: 'Choisissez une date et une heure de début.', end: 'Choisissez une heure de fin dans les 24 heures après le début.',
|
|
156
|
+
past: 'Choisissez une heure qui n’est pas passée.', far: 'Choisissez une date dans les deux prochaines années.', repeat: 'Dites-nous comment cela se répète.', room: 'Choisissez une salle dans la liste.',
|
|
157
|
+
people: 'Vérifiez le nombre de personnes.', event: 'Choisissez un événement dans la liste.', date: 'Cet événement n’a pas lieu à cette date.', what: 'Dites-nous ce qu’il faut changer.',
|
|
158
|
+
name: 'Indiquez votre nom.', email: 'Indiquez une adresse e-mail valide.', phone: 'Vérifiez le numéro de téléphone ou laissez-le vide.', failed: 'Impossible d’envoyer votre demande. Réessayez.',
|
|
159
|
+
},
|
|
160
|
+
mail: {
|
|
161
|
+
receivedSubject: 'Nous avons reçu votre demande', received: 'Nous avons reçu votre demande d’événement. Elle n’est pas au calendrier tant que nous ne l’avons pas confirmée.', within: 'Nous répondons généralement sous {time}.',
|
|
162
|
+
replySubject: 'À propos de votre demande d’événement', reply: 'Nous vous écrivons au sujet de votre demande :',
|
|
163
|
+
approvedSubject: 'Votre demande est acceptée', approved: 'Nous avons accepté votre demande d’événement. Nous vous écrirons si nous avons besoin d’autre chose.', approvedChange: 'Nous avons accepté votre demande et ferons le changement.',
|
|
164
|
+
declinedSubject: 'À propos de votre demande d’événement', declined: 'Nous sommes désolés, nous ne pouvons pas accepter votre demande d’événement.', declinedChange: 'Nous sommes désolés, nous ne pouvons pas faire le changement demandé.',
|
|
165
|
+
fromTeam: 'De l’équipe :', notYou: 'Si vous n’avez pas envoyé cette demande, ignorez cet e-mail.',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
de: {
|
|
169
|
+
heading: 'Veranstaltung anfragen', notice: 'Wir prüfen jede Anfrage. Sie steht erst im Kalender, wenn wir sie bestätigen.',
|
|
170
|
+
somethingNew: 'Etwas Neues', change: 'Änderung einer Veranstaltung', what: 'Was planen Sie?', type: 'Art', date: 'Datum', starts: 'Beginn', ends: 'Ende',
|
|
171
|
+
repeats: 'Wiederholung', once: 'Nur einmal', weekly: 'Jede Woche', other: 'Etwas anderes', otherHint: 'Sagen Sie uns, wie es sich wiederholt',
|
|
172
|
+
room: 'Raum', notSure: 'Weiß nicht', free: '{room}: zu dieser Zeit frei.', taken: '{room}: zu dieser Zeit nicht frei.', people: 'Wie viele Personen?', message: 'Sollten wir noch etwas wissen?', optional: 'Optional',
|
|
173
|
+
event: 'Welche Veranstaltung?', pickEvent: 'Veranstaltung wählen', onDate: 'Welches Datum?', onDateHint: 'Leer lassen, wenn es um alle Termine geht.', whatChange: 'Was soll sich ändern?',
|
|
174
|
+
contact: 'So erreichen wir Sie', name: 'Ihr Name', email: 'E-Mail', phone: 'Telefon', contactWhy: 'Wir verwenden dies nur, um Ihre Anfrage zu beantworten.', privacy: 'Datenschutz',
|
|
175
|
+
send: 'Anfrage senden', closed: 'Wir nehmen hier gerade keine Anfragen an.', call: 'Rufen Sie uns an: {phone}.',
|
|
176
|
+
sent: 'Anfrage gesendet', answerWithin: 'Wir antworten normalerweise innerhalb von {time}, per E-Mail an {email}.', answerBy: 'Wir antworten per E-Mail an {email}.',
|
|
177
|
+
yours: 'Ihre Anfrage', notYet: 'Sie steht erst im Kalender, wenn wir sie bestätigen.', sooner: 'Eilt es? Rufen Sie {phone} an.', another: 'Weitere Anfrage senden',
|
|
178
|
+
aboutPeople: 'etwa {count} Personen', changeTo: 'Änderung: {title}', everyDate: 'alle Termine',
|
|
179
|
+
errors: {
|
|
180
|
+
closed: 'Wir nehmen hier gerade keine Anfragen an.', trap: 'Diese Anfrage konnte nicht gesendet werden.', busy: 'Zu viele Anfragen. Versuchen Sie es in einer Minute erneut.',
|
|
181
|
+
full: 'Heute sind viele Anfragen eingegangen. Bitte versuchen Sie es morgen erneut.', changed: 'Diese Anfrage wurde schon gesendet. Laden Sie die Seite neu, um eine neue zu senden.',
|
|
182
|
+
title: 'Sagen Sie uns, was Sie planen.', type: 'Wählen Sie eine Art aus der Liste.', start: 'Wählen Sie ein Datum und eine Beginnzeit.', end: 'Wählen Sie ein Ende innerhalb eines Tages nach dem Beginn.',
|
|
183
|
+
past: 'Wählen Sie eine Zeit, die noch nicht vorbei ist.', far: 'Wählen Sie ein Datum in den nächsten zwei Jahren.', repeat: 'Sagen Sie uns, wie es sich wiederholt.', room: 'Wählen Sie einen Raum aus der Liste.',
|
|
184
|
+
people: 'Prüfen Sie die Zahl der Personen.', event: 'Wählen Sie eine Veranstaltung aus der Liste.', date: 'An diesem Datum findet die Veranstaltung nicht statt.', what: 'Sagen Sie uns, was sich ändern soll.',
|
|
185
|
+
name: 'Geben Sie Ihren Namen an.', email: 'Geben Sie eine gültige E-Mail-Adresse an.', phone: 'Prüfen Sie die Telefonnummer oder lassen Sie sie leer.', failed: 'Ihre Anfrage konnte nicht gesendet werden. Bitte versuchen Sie es erneut.',
|
|
186
|
+
},
|
|
187
|
+
mail: {
|
|
188
|
+
receivedSubject: 'Wir haben Ihre Anfrage erhalten', received: 'Wir haben Ihre Veranstaltungsanfrage erhalten. Sie steht erst im Kalender, wenn wir sie bestätigen.', within: 'Wir antworten normalerweise innerhalb von {time}.',
|
|
189
|
+
replySubject: 'Zu Ihrer Veranstaltungsanfrage', reply: 'Wir schreiben Ihnen zu Ihrer Anfrage:',
|
|
190
|
+
approvedSubject: 'Ihre Anfrage wurde angenommen', approved: 'Wir haben Ihre Veranstaltungsanfrage angenommen. Wir melden uns, falls wir noch etwas brauchen.', approvedChange: 'Wir haben Ihre Anfrage angenommen und nehmen die Änderung vor.',
|
|
191
|
+
declinedSubject: 'Zu Ihrer Veranstaltungsanfrage', declined: 'Es tut uns leid, wir können Ihre Veranstaltungsanfrage nicht annehmen.', declinedChange: 'Es tut uns leid, wir können die gewünschte Änderung nicht vornehmen.',
|
|
192
|
+
fromTeam: 'Vom Team:', notYou: 'Wenn Sie diese Anfrage nicht gesendet haben, ignorieren Sie diese E-Mail.',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
pt: {
|
|
196
|
+
heading: 'Solicitar um evento', notice: 'Analisamos cada solicitação. Ela não entra no calendário até que a confirmemos.',
|
|
197
|
+
somethingNew: 'Algo novo', change: 'Uma mudança em um evento', what: 'O que você está planejando?', type: 'Tipo', date: 'Data', starts: 'Começa', ends: 'Termina',
|
|
198
|
+
repeats: 'Repete', once: 'Só uma vez', weekly: 'Toda semana', other: 'Outra coisa', otherHint: 'Conte como se repete',
|
|
199
|
+
room: 'Sala', notSure: 'Não sei', free: '{room}: livre nesse horário.', taken: '{room}: não está livre nesse horário.', people: 'Quantas pessoas?', message: 'Algo mais que devemos saber?', optional: 'Opcional',
|
|
200
|
+
event: 'Qual evento?', pickEvent: 'Escolha um evento', onDate: 'Qual data?', onDateHint: 'Deixe vazio se for sobre todas as datas.', whatChange: 'O que deve mudar?',
|
|
201
|
+
contact: 'Como falamos com você', name: 'Seu nome', email: 'E-mail', phone: 'Telefone', contactWhy: 'Usamos isso só para responder à sua solicitação.', privacy: 'Privacidade',
|
|
202
|
+
send: 'Enviar solicitação', closed: 'No momento não estamos recebendo solicitações aqui.', call: 'Ligue para {phone}.',
|
|
203
|
+
sent: 'Solicitação enviada', answerWithin: 'Normalmente respondemos em {time}, por e-mail para {email}.', answerBy: 'Responderemos por e-mail para {email}.',
|
|
204
|
+
yours: 'Sua solicitação', notYet: 'Ela não entra no calendário até que a confirmemos.', sooner: 'Precisa antes? Ligue para {phone}.', another: 'Enviar outra solicitação',
|
|
205
|
+
aboutPeople: 'cerca de {count} pessoas', changeTo: 'Mudança em {title}', everyDate: 'todas as datas',
|
|
206
|
+
errors: {
|
|
207
|
+
closed: 'No momento não estamos recebendo solicitações aqui.', trap: 'Não foi possível enviar esta solicitação.', busy: 'Muitas solicitações. Tente de novo em um minuto.',
|
|
208
|
+
full: 'Recebemos muitas solicitações hoje. Tente de novo amanhã.', changed: 'Esta solicitação já foi enviada. Recarregue a página para enviar outra.',
|
|
209
|
+
title: 'Conte o que você está planejando.', type: 'Escolha um tipo da lista.', start: 'Escolha uma data e um horário de início.', end: 'Escolha um horário de término até um dia depois do início.',
|
|
210
|
+
past: 'Escolha um horário que ainda não passou.', far: 'Escolha uma data nos próximos dois anos.', repeat: 'Conte como se repete.', room: 'Escolha uma sala da lista.',
|
|
211
|
+
people: 'Confira o número de pessoas.', event: 'Escolha um evento da lista.', date: 'Esse evento não acontece nessa data.', what: 'Conte o que deve mudar.',
|
|
212
|
+
name: 'Informe seu nome.', email: 'Informe um e-mail válido.', phone: 'Confira o telefone ou deixe-o vazio.', failed: 'Não foi possível enviar sua solicitação. Tente de novo.',
|
|
213
|
+
},
|
|
214
|
+
mail: {
|
|
215
|
+
receivedSubject: 'Recebemos sua solicitação', received: 'Recebemos sua solicitação de evento. Ela não entra no calendário até que a confirmemos.', within: 'Normalmente respondemos em {time}.',
|
|
216
|
+
replySubject: 'Sobre sua solicitação de evento', reply: 'Escrevemos sobre sua solicitação:',
|
|
217
|
+
approvedSubject: 'Sua solicitação foi aprovada', approved: 'Aprovamos sua solicitação de evento. Entraremos em contato se precisarmos de mais alguma coisa.', approvedChange: 'Aprovamos sua solicitação e faremos a mudança.',
|
|
218
|
+
declinedSubject: 'Sobre sua solicitação de evento', declined: 'Sentimos muito, não podemos aceitar sua solicitação de evento.', declinedChange: 'Sentimos muito, não podemos fazer a mudança que você pediu.',
|
|
219
|
+
fromTeam: 'Da equipe:', notYou: 'Se você não enviou esta solicitação, ignore este e-mail.',
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const requestStrings = (locale: string | null | undefined): RequestStrings =>
|
|
225
|
+
STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
|
|
226
|
+
|
|
227
|
+
// The staff alert and its push, in the site's main language.
|
|
228
|
+
export interface RequestStaffWords {
|
|
229
|
+
subjectNew: string; subjectChange: string; subjectMove: string;
|
|
230
|
+
intro: Record<'app' | 'web' | 'member', string>;
|
|
231
|
+
when: string; repeats: string; rooms: string; roomsUnsure: string; people: string; type: string; from: string; language: string;
|
|
232
|
+
message: string; whatChange: string; date: string; everyDate: string; open: string; why: string;
|
|
233
|
+
pushTitle: string; pushBody: string;
|
|
234
|
+
}
|
|
235
|
+
const STAFF: Record<string, RequestStaffWords> = {
|
|
236
|
+
en: { subjectNew: 'New event request: {title}', subjectChange: 'Change requested: {title}', subjectMove: 'Date move to review: {title}',
|
|
237
|
+
intro: { app: 'Sent from the app.', web: 'Sent from the website.', member: 'Sent by a host from My events.' },
|
|
238
|
+
when: 'When', repeats: 'Repeats', rooms: 'Rooms', roomsUnsure: 'Not sure', people: 'People', type: 'Kind', from: 'From', language: 'Language',
|
|
239
|
+
message: 'Their message', whatChange: 'What should change', date: 'Date', everyDate: 'Every date', open: 'Review requests',
|
|
240
|
+
why: 'You get these because you’re on the list for new event requests.', pushTitle: 'New event request', pushBody: 'For {when}. Review it in the admin.' },
|
|
241
|
+
es: { subjectNew: 'Nueva solicitud de evento: {title}', subjectChange: 'Cambio solicitado: {title}', subjectMove: 'Cambio de fecha por revisar: {title}',
|
|
242
|
+
intro: { app: 'Enviada desde la app.', web: 'Enviada desde el sitio web.', member: 'Enviada por un anfitrión desde Mis eventos.' },
|
|
243
|
+
when: 'Cuándo', repeats: 'Se repite', rooms: 'Salones', roomsUnsure: 'No está seguro', people: 'Personas', type: 'Tipo', from: 'De', language: 'Idioma',
|
|
244
|
+
message: 'Su mensaje', whatChange: 'Qué debe cambiar', date: 'Fecha', everyDate: 'Todas las fechas', open: 'Revisar solicitudes',
|
|
245
|
+
why: 'Recibe estos correos porque está en la lista de nuevas solicitudes de eventos.', pushTitle: 'Nueva solicitud de evento', pushBody: 'Para el {when}. Revísela en el panel.' },
|
|
246
|
+
fr: { subjectNew: 'Nouvelle demande d’événement : {title}', subjectChange: 'Changement demandé : {title}', subjectMove: 'Changement de date à examiner : {title}',
|
|
247
|
+
intro: { app: 'Envoyée depuis l’app.', web: 'Envoyée depuis le site.', member: 'Envoyée par un responsable depuis Mes événements.' },
|
|
248
|
+
when: 'Quand', repeats: 'Se répète', rooms: 'Salles', roomsUnsure: 'Ne sait pas', people: 'Personnes', type: 'Type', from: 'De', language: 'Langue',
|
|
249
|
+
message: 'Son message', whatChange: 'Ce qu’il faut changer', date: 'Date', everyDate: 'Toutes les dates', open: 'Voir les demandes',
|
|
250
|
+
why: 'Vous recevez ces e-mails parce que vous êtes sur la liste des nouvelles demandes d’événements.', pushTitle: 'Nouvelle demande d’événement', pushBody: 'Pour le {when}. À examiner dans l’administration.' },
|
|
251
|
+
de: { subjectNew: 'Neue Veranstaltungsanfrage: {title}', subjectChange: 'Änderung angefragt: {title}', subjectMove: 'Terminverschiebung zu prüfen: {title}',
|
|
252
|
+
intro: { app: 'Über die App gesendet.', web: 'Über die Website gesendet.', member: 'Von einer Leitung über Meine Veranstaltungen gesendet.' },
|
|
253
|
+
when: 'Wann', repeats: 'Wiederholung', rooms: 'Räume', roomsUnsure: 'Weiß nicht', people: 'Personen', type: 'Art', from: 'Von', language: 'Sprache',
|
|
254
|
+
message: 'Nachricht', whatChange: 'Was sich ändern soll', date: 'Datum', everyDate: 'Alle Termine', open: 'Anfragen ansehen',
|
|
255
|
+
why: 'Sie erhalten diese E-Mails, weil Sie auf der Liste für neue Veranstaltungsanfragen stehen.', pushTitle: 'Neue Veranstaltungsanfrage', pushBody: 'Für {when}. Bitte in der Verwaltung prüfen.' },
|
|
256
|
+
pt: { subjectNew: 'Nova solicitação de evento: {title}', subjectChange: 'Mudança solicitada: {title}', subjectMove: 'Mudança de data para revisar: {title}',
|
|
257
|
+
intro: { app: 'Enviada pelo app.', web: 'Enviada pelo site.', member: 'Enviada por um anfitrião em Meus eventos.' },
|
|
258
|
+
when: 'Quando', repeats: 'Repete', rooms: 'Salas', roomsUnsure: 'Não sabe', people: 'Pessoas', type: 'Tipo', from: 'De', language: 'Idioma',
|
|
259
|
+
message: 'Mensagem', whatChange: 'O que deve mudar', date: 'Data', everyDate: 'Todas as datas', open: 'Ver solicitações',
|
|
260
|
+
why: 'Você recebe estes e-mails porque está na lista de novas solicitações de eventos.', pushTitle: 'Nova solicitação de evento', pushBody: 'Para {when}. Revise no painel.' },
|
|
261
|
+
};
|
|
262
|
+
export const requestStaffWords = (locale: string | null | undefined): RequestStaffWords =>
|
|
263
|
+
STAFF[(locale || 'en').toLowerCase().split('-')[0]] ?? STAFF.en;
|
package/src/lib/email/service.ts
CHANGED
|
@@ -39,6 +39,11 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
|
|
|
39
39
|
return { ...mail, to: job.recipient };
|
|
40
40
|
}
|
|
41
41
|
if (job.kind === 'form-alert') return formAlertMail(repo, job.recipient, JSON.parse(job.payload) as AlertPayload, origin);
|
|
42
|
+
// event requests without an account (lib/calendar/request-service.ts)
|
|
43
|
+
if (job.kind === 'calendar-request') {
|
|
44
|
+
const { requestMail } = await import('../calendar/request-mail');
|
|
45
|
+
return requestMail(repo, job.recipient, JSON.parse(job.payload), origin);
|
|
46
|
+
}
|
|
42
47
|
if (job.kind === 'auth') {
|
|
43
48
|
const payload = JSON.parse(job.payload);
|
|
44
49
|
const token = await repo.email.token(payload.tokenId);
|
|
@@ -109,7 +114,7 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
109
114
|
}
|
|
110
115
|
await repo.email.pin(j,resolved.provider,resolved.fingerprint,config?.version || 0);
|
|
111
116
|
if (await repo.email.suppressed(j.recipient)) { await repo.email.finish(j,{state:'suppressed',error:'Recipient is suppressed after a bounce or complaint.'}); continue; }
|
|
112
|
-
if (j.kind === 'booking' || j.kind === 'event-request') {
|
|
117
|
+
if (j.kind === 'booking' || j.kind === 'event-request' || j.kind === 'calendar-request') {
|
|
113
118
|
const payload = JSON.parse(j.payload);
|
|
114
119
|
if ((payload.kind === 'reminder' && !current.config.reminders) || (String(payload.kind).startsWith('staff-') && !current.config.staffNotifications)) {
|
|
115
120
|
await repo.email.finish(j,{state:'cancelled',error:'Optional notification disabled.'}); continue;
|
|
@@ -11,7 +11,7 @@ export interface AppPrivacyAnswers { rows: StoreRow[]; tracking: string; notes:
|
|
|
11
11
|
export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacyAnswers {
|
|
12
12
|
const rows: StoreRow[] = [];
|
|
13
13
|
// apps can RSVP (or join a full date's waitlist) wherever the calendar is on; room bookings need bookings too
|
|
14
|
-
const contact = [...(f.bookings ? ['bookings'] : []), ...(f.calendar ? ['RSVPs (including waitlist requests)'] : []), ...(f.appForms ? ['forms'] : [])];
|
|
14
|
+
const contact = [...(f.bookings ? ['bookings'] : []), ...(f.calendar ? ['RSVPs (including waitlist requests)'] : []), ...(f.appForms ? ['forms'] : []), ...(f.requests.enabled ? ['event requests'] : [])];
|
|
15
15
|
const sends = contact.length > 1 ? `${contact.slice(0, -1).join(', ')} or ${contact.at(-1)}` : contact[0];
|
|
16
16
|
if (contact.length) {
|
|
17
17
|
rows.push({
|
|
@@ -20,7 +20,7 @@ export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacy
|
|
|
20
20
|
google: 'Personal info → Name, Email address, Phone number. Collected, not shared. Purpose: App functionality. Optional (people choose to send it).',
|
|
21
21
|
});
|
|
22
22
|
rows.push({
|
|
23
|
-
data: 'Answers typed into a form or booking question', when: `When someone sends ${sends} from the app`,
|
|
23
|
+
data: f.requests.enabled ? 'Answers typed into a form or booking question, and the message of an event request' : 'Answers typed into a form or booking question', when: `When someone sends ${sends} from the app`,
|
|
24
24
|
apple: 'User Content → Other User Content. Purpose: App Functionality. Linked to the user. Not used for tracking.',
|
|
25
25
|
google: 'App activity → Other user-generated content. Collected, not shared. Purpose: App functionality. Optional.',
|
|
26
26
|
});
|
package/src/lib/legal/facts.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Settings } from '../schema';
|
|
|
4
4
|
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
5
5
|
import { PUSH } from '../push/config';
|
|
6
6
|
import { ANNOUNCEMENTS } from '../announcements/config';
|
|
7
|
+
import { REQUESTS } from '../calendar/request-config';
|
|
7
8
|
import { AUTH } from '../auth/config';
|
|
8
9
|
import { EDITOR } from '../editor/config';
|
|
9
10
|
import { SITE_ROLES } from '../roles';
|
|
@@ -44,6 +45,8 @@ export interface LegalFacts {
|
|
|
44
45
|
googleCalendar: boolean;
|
|
45
46
|
/** acms({ announcements }): urgent ones reach every device; read and closed state stays in the app */
|
|
46
47
|
announcements: boolean;
|
|
48
|
+
/** acms({ requests }): event requests without an account, kept keepDays after the answer */
|
|
49
|
+
requests: { enabled: boolean; keepDays: number };
|
|
47
50
|
analytics: { umami: boolean; replay: boolean; meta: boolean; tiktok: boolean };
|
|
48
51
|
ai: boolean;
|
|
49
52
|
offsiteBackups: boolean;
|
|
@@ -86,6 +89,7 @@ export async function legalFacts(repo: ContentRepository, settings: Settings, lo
|
|
|
86
89
|
live: settings.live.mode !== 'off',
|
|
87
90
|
googleCalendar: CALENDAR.enabled && await repo.gcal.account().then((a) => !!a && a.status !== 'setup').catch(() => false),
|
|
88
91
|
announcements: ANNOUNCEMENTS.enabled,
|
|
92
|
+
requests: { enabled: REQUESTS.enabled, keepDays: settings.calendar.requests.keepDays },
|
|
89
93
|
analytics: {
|
|
90
94
|
umami: a.umami.enabled && !!a.umami.websiteId,
|
|
91
95
|
replay: a.umami.enabled && !!a.umami.websiteId && a.umami.replay.enabled,
|
package/src/lib/legal/text-en.ts
CHANGED
|
@@ -49,6 +49,9 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
|
49
49
|
s.push({ id: 'forms', title: 'Forms', text: [
|
|
50
50
|
`When you send a form, we store your answers as you entered them (for example your name, email address or phone number, if the form asks for them) and when you sent it. Staff with access to form responses can read and export them. We don’t store your IP address with a response.${f.email ? ' Responses, or a notice that one arrived, may be emailed to the staff who handle that form.' : ''}`,
|
|
51
51
|
] });
|
|
52
|
+
if (f.requests.enabled) s.push({ id: 'requests', title: 'Event requests', text: [
|
|
53
|
+
`When you ask us for an event, or a change to one, on this site${app ? ' or in our app' : ''}, we store your name, email address, any phone number, the language you used, the details you give (what, when, which rooms, how many people) and your message. We use them only to answer your request: staff who manage the calendar read it, and we email you that it arrived and our answer. Your contact details never appear on a published event. We delete a request ${days(f.requests.keepDays)} after we answer it, or ${days(f.requests.keepDays)} after it arrives if we never do; site backups may keep a copy for a while longer.`,
|
|
54
|
+
] });
|
|
52
55
|
if (f.guestSignIn) s.push({ id: 'guest', title: 'Signing in to see your bookings', text: [
|
|
53
56
|
'To see your bookings in our app, you can sign in with your email address. We email you a six-digit code that expires after 10 minutes. We keep your email address and that sign-in on your device until you sign out or don’t use it for 90 days.',
|
|
54
57
|
] });
|
package/src/lib/legal/text-es.ts
CHANGED
|
@@ -50,6 +50,9 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
|
50
50
|
s.push({ id: 'forms', title: 'Formularios', text: [
|
|
51
51
|
`Cuando envía un formulario, guardamos sus respuestas tal como las escribió (por ejemplo su nombre, correo o teléfono, si el formulario los pide) y la fecha en que lo envió. El personal con acceso a las respuestas puede leerlas y exportarlas. No guardamos su dirección IP con la respuesta.${f.email ? ' Sus respuestas, o un aviso de que llegaron, pueden enviarse por correo al personal que atiende ese formulario.' : ''}`,
|
|
52
52
|
] });
|
|
53
|
+
if (f.requests.enabled) s.push({ id: 'requests', title: 'Solicitudes de eventos', text: [
|
|
54
|
+
`Cuando nos pide un evento, o un cambio a uno, en este sitio${app ? ' o en nuestra app' : ''}, guardamos su nombre, su correo electrónico, el teléfono si lo da, el idioma que usó, los detalles que nos da (qué, cuándo, qué salones, cuántas personas) y su mensaje. Los usamos solo para responder a su solicitud: el personal que administra el calendario la lee, y le escribimos por correo que llegó y nuestra respuesta. Sus datos de contacto nunca aparecen en un evento publicado. Borramos la solicitud ${days(f.requests.keepDays)} después de responderla, o ${days(f.requests.keepDays)} después de recibirla si no la respondemos; las copias de seguridad del sitio pueden conservar una copia un tiempo más.`,
|
|
55
|
+
] });
|
|
53
56
|
if (f.guestSignIn) s.push({ id: 'guest', title: 'Inicio de sesión para ver sus reservaciones', text: [
|
|
54
57
|
'Para ver sus reservaciones en nuestra app, puede iniciar sesión con su correo electrónico. Le enviamos un código de seis dígitos que vence a los 10 minutos. Guardamos su correo y esa sesión en su dispositivo hasta que la cierre o pasen 90 días sin usarla.',
|
|
55
58
|
] });
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -33,7 +33,8 @@ 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
|
|
36
|
+
// a refusal's own code (BookingError, or an ApiError with one: requests, moves)
|
|
37
|
+
code: ((error instanceof BookingError || error instanceof ApiError) && typeof (error as { code?: unknown }).code === 'string' && (error as { code: string }).code) || (codes[status] ?? 'internal_error'),
|
|
37
38
|
message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
|
|
38
39
|
...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
|
|
39
40
|
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': String((error as ApiError).retryAfter ?? 60) } : {}),
|
|
@@ -9,6 +9,8 @@ import { pagesQuery } from './pages';
|
|
|
9
9
|
import { searchQuery } from './search';
|
|
10
10
|
import { manageAvailabilityQuery, rescheduleInput } from './manage';
|
|
11
11
|
import { formResponseInput } from './forms';
|
|
12
|
+
import { requestOptionsQuery } from './requests';
|
|
13
|
+
import { RequestInput } from '../calendar/request-service';
|
|
12
14
|
import { codeInput, tokenInput, myBookingsQuery } from './guest';
|
|
13
15
|
import { deviceInput, deviceUpdate, staffLinkInput } from '../push/api';
|
|
14
16
|
import { announcementsQuery } from '../announcements/api';
|
|
@@ -116,6 +118,12 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
116
118
|
paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
|
|
117
119
|
description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409 (a translation alone doesn’t change it). A choice may be sent as shown in the requested language or in the main one; it is stored as the main-language choice. A time answer is HH:mm (24-hour). locale: the confirmation in that language (one the site doesn’t have gets the main language’s). Leave website empty (spam trap).' }),
|
|
118
120
|
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'locale', in: 'query', required: false, schema: { type: 'string', minLength: 1, maxLength: 35 } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
121
|
+
const requestNote = 'Requires acms({ requests }) (site.features.requests); 404 when off. No account.';
|
|
122
|
+
paths['/api/v1/requests/options'] = { get: priv('What the event request form offers: open or closed, the types, requestable rooms and changeable events (with their dates), the site’s reply time and notice (in the requested language) and its phone', ref('RequestOptions'), { query: requestOptionsQuery,
|
|
123
|
+
description: `${requestNote} Never cached. When open is false, say so and show phone; keep the screen itself reachable. Check whether a room is free with GET /rooms/free.` }), ...cors };
|
|
124
|
+
paths['/api/v1/requests'] = { post: { ...priv('Ask for a new event or a change to one, without an account', ref('RequestReceipt'), { body: RequestInput, write: true, status: 201,
|
|
125
|
+
description: `${requestNote} kind new: type (from options; required when the site lists types), title, start (site-local YYYY-MM-DDTHH:mm, in the future), end (the same, or HH:mm: the start's day, or the next when it's before the start; within a day), repeat (once, weekly, or other with repeatText), rooms (slugs from options) or roomsUnsure, people, message. kind change: event (a public event's slug), date (YYYY-MM-DD, optional: one of its dates; none = every date), message (what should change, required). name and email are required, phone optional; locale is the language for replies. Idempotency-Key (UUID v4) is the request's id: a retry answers the same, other details with the same key answer 409 changed. Refusals carry a code (see Error). 10 requests a minute and 20 a day per client address, 5 a day per email address, and the site's daily cap (429). A retry is answered before any limit. Leave website empty (spam trap). The person gets an email that it arrived, without the words they typed.` }),
|
|
126
|
+
parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
119
127
|
const guestNote = 'Guest sign-in needs the calendar, working email, EMAIL_ENCRYPTION_KEY and the owner switch guestSignIn (site.features.guestSignIn); 404 otherwise.';
|
|
120
128
|
paths['/api/v1/guest/code'] = { post: priv('Email a 6-digit sign-in code (always answers the same, whether or not the address has bookings)', ref('GuestCodeSent'), { body: codeInput, write: true, status: 202,
|
|
121
129
|
description: `${guestNote} Codes last 10 minutes; a new request voids the old code. 3 codes per address and 20 per client address per 15 minutes.` }), ...cors };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ApiError, readJson } from '../api';
|
|
3
|
+
import { REQUESTS } from '../calendar/request-config';
|
|
4
|
+
import { requestOptions, submitRequest } from '../calendar/request-service';
|
|
5
|
+
import { queryParams } from './http';
|
|
6
|
+
import type { PublicContext } from './route';
|
|
7
|
+
|
|
8
|
+
// Event requests from apps (acms({ requests })): the options the form needs,
|
|
9
|
+
// and sending one. No account; an Idempotency-Key per request (retries never
|
|
10
|
+
// duplicate); 10 a minute per address and the site's daily cap
|
|
11
|
+
// (lib/calendar/request-service.ts holds the rules). Never cached: "closed"
|
|
12
|
+
// and the rooms must show at once.
|
|
13
|
+
|
|
14
|
+
export const requestOptionsQuery = z.object({ locale: z.string().max(35).optional() }).strict();
|
|
15
|
+
|
|
16
|
+
export async function getRequestOptions(c: PublicContext) {
|
|
17
|
+
if (!REQUESTS.enabled) throw new ApiError(404, 'Not found.');
|
|
18
|
+
const q = requestOptionsQuery.parse(queryParams(c.url));
|
|
19
|
+
return requestOptions(c.repo, q.locale);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function postRequest(c: PublicContext) {
|
|
23
|
+
const key = c.request.headers.get('idempotency-key') ?? '';
|
|
24
|
+
const body = await readJson(c.request, 20_000);
|
|
25
|
+
const { id } = await submitRequest(c.repo, body, { source: 'app', key, address: c.address, locals: c.locals });
|
|
26
|
+
return { id };
|
|
27
|
+
}
|
|
@@ -9,7 +9,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
|
|
|
9
9
|
export const Site = z.object({
|
|
10
10
|
name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
|
|
11
11
|
languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
|
|
12
|
-
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, rsvp: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool }),
|
|
12
|
+
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, rsvp: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool, requests: bool }),
|
|
13
13
|
legal: z.object({ privacy: str.nullable(), terms: str.nullable() }), apiVersion: z.literal(1),
|
|
14
14
|
});
|
|
15
15
|
export const Theme = z.object({
|
|
@@ -159,8 +159,19 @@ export const LiveStatus = z.object({
|
|
|
159
159
|
snapshot: LiveSnapshot.nullable(), serverNow: ms,
|
|
160
160
|
display: z.object({ style: z.enum(['poster', 'split', 'row']), bar: bool, header: bool, dock: bool }),
|
|
161
161
|
});
|
|
162
|
+
// GET /requests/options: types and rooms to offer, the site's words (in the asked language), the phone for when it's closed
|
|
163
|
+
export const RequestOptions = z.object({
|
|
164
|
+
open: bool, types: z.array(z.object({ id: str, label: str })), rooms: z.array(z.object({ slug: str, name: str, color: str })),
|
|
165
|
+
replyTime: str, notice: str, phone: str, timezone: str,
|
|
166
|
+
// what a change can be about: public events by title, each with its dates (site-local days) in the next 120 days
|
|
167
|
+
events: z.array(z.object({ slug: str, title: str, dates: z.array(str) })),
|
|
168
|
+
});
|
|
169
|
+
export const RequestReceipt = z.object({ id: str });
|
|
162
170
|
export const Error = z.object({ version: z.literal(1), error: z.object({
|
|
163
|
-
// duplicate (409): that email already has an RSVP for the date
|
|
164
|
-
|
|
171
|
+
// duplicate (409): that email already has an RSVP for the date. POST /requests refusals say
|
|
172
|
+
// which field (title, type, start, end, past, far, repeat, room, people, event, date, what,
|
|
173
|
+
// name, email, phone; 400), closed or changed (409), busy or full (429), trap (400)
|
|
174
|
+
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',
|
|
175
|
+
'closed', 'trap', 'busy', 'full', 'changed', 'title', 'type', 'start', 'end', 'past', 'far', 'repeat', 'room', 'people', 'event', 'date', 'what', 'name', 'email', 'phone']),
|
|
165
176
|
message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
|
|
166
177
|
}) });
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { CONTENT, legacyBases } from '../content';
|
|
|
4
4
|
import { I18N, localeName } from '../i18n';
|
|
5
5
|
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
6
6
|
import { ANNOUNCEMENTS } from '../announcements/config';
|
|
7
|
+
import { REQUESTS } from '../calendar/request-config';
|
|
7
8
|
import { SEARCH } from '../search-config';
|
|
8
9
|
import { siteOrigin } from '../seo';
|
|
9
10
|
import { resolveDesign } from '../theme';
|
|
@@ -51,6 +52,8 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
51
52
|
live: settings.live.mode !== 'off',
|
|
52
53
|
// acms({ announcements }): GET /api/v1/announcements
|
|
53
54
|
announcements: ANNOUNCEMENTS.enabled,
|
|
55
|
+
// acms({ requests }): GET /api/v1/requests/options, POST /api/v1/requests (on even while requests are closed)
|
|
56
|
+
requests: REQUESTS.enabled,
|
|
54
57
|
},
|
|
55
58
|
// in the requested language (?locale=es → /es/privacy)
|
|
56
59
|
legal: { privacy: legalUrl('privacy'), terms: legalUrl('terms') },
|
package/src/lib/push/types.ts
CHANGED
|
@@ -57,7 +57,7 @@ export interface PushDevice {
|
|
|
57
57
|
* new form response, to the linked phones of staff on its alert list. Neither
|
|
58
58
|
* counts in the weekly pace.
|
|
59
59
|
*/
|
|
60
|
-
export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change' | 'test' | 'form';
|
|
60
|
+
export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change' | 'test' | 'form' | 'request';
|
|
61
61
|
export interface PushNotification {
|
|
62
62
|
id: string; title: string; body: string; url: string;
|
|
63
63
|
/** ['*'] = everyone */
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { dispatchEmail } from './email/service';
|
|
|
6
6
|
import { decideOverdue, sendDueReminders, sweepWaitlist } from './booking/service';
|
|
7
7
|
import { runPushJobs } from './push/service';
|
|
8
8
|
import { runAnnouncementJobs } from './announcements/service';
|
|
9
|
+
import { pruneRequests } from './calendar/request-service';
|
|
9
10
|
import { GUEST_IDLE_MS } from './mobile/guest-store';
|
|
10
11
|
import { pushHeartbeat, pushUrlFor } from './monitoring';
|
|
11
12
|
import { syncCachePurge } from './cache/purge';
|
|
@@ -35,6 +36,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
35
36
|
announcements: () => runAnnouncementJobs(repo, { locals, now: startedAt }),
|
|
36
37
|
// queued first-publish notices, starting-soon reminders, receipts, retention
|
|
37
38
|
push: () => runPushJobs(repo, { locals, now: startedAt }),
|
|
39
|
+
// event requests past Calendar → Requests' retention (after the answer, or after arriving if nobody answered)
|
|
40
|
+
requests: () => pruneRequests(repo, startedAt),
|
|
38
41
|
// expired guest sign-in codes and app sessions unused for 90 days
|
|
39
42
|
guests: () => repo.guests.purge(GUEST_IDLE_MS, startedAt).then(() => 0),
|
|
40
43
|
// clear shared caches after scheduled publishes, and retry a purge that
|
package/src/lib/schema.ts
CHANGED
|
@@ -406,8 +406,10 @@ export type EventRequest = z.infer<typeof EventRequest>;
|
|
|
406
406
|
// on (C3/C4); `data` holds the move.
|
|
407
407
|
// `userId` is the member who asked; `requester` is the person without an
|
|
408
408
|
// account (never both). `note` is their own words to staff, `reply` staff's
|
|
409
|
-
// last answer.
|
|
410
|
-
// are deleted some days after
|
|
409
|
+
// last answer. `data.sent` on new and change requests is a hash of what was
|
|
410
|
+
// sent (a retry must match it). Rows are deleted some days after
|
|
411
|
+
// `reviewedAt` (withdrawing sets it too), or after `createdAt` while nobody
|
|
412
|
+
// answered (CalendarSettings.requests).
|
|
411
413
|
export const RequestKind = z.enum(['new', 'change', 'move', 'from-date']);
|
|
412
414
|
export type RequestKind = z.infer<typeof RequestKind>;
|
|
413
415
|
export const RequestState = z.enum(['pending', 'approved', 'declined', 'withdrawn']);
|
|
@@ -651,6 +653,44 @@ export type ApprovalRules = z.infer<typeof ApprovalRules>;
|
|
|
651
653
|
export const EventImageUrl = z.string().trim().max(2000).refine((value) => !value || Boolean(safeEventImageUrl(value)), 'Choose an image or use an http(s) URL or site-relative image path.');
|
|
652
654
|
|
|
653
655
|
// Site-wide calendar preferences (Calendar settings → Defaults / Bookings)
|
|
656
|
+
// Event requests from people without an account (acms({ requests }),
|
|
657
|
+
// lib/calendar/request-service.ts), set on Calendar → Requests. A type is
|
|
658
|
+
// what people pick under "Kind" (Class, Meeting…): approving a request makes
|
|
659
|
+
// a draft with its tag, in its calendar.
|
|
660
|
+
export const RequestType = z.object({
|
|
661
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/),
|
|
662
|
+
label: z.string().min(1).max(60),
|
|
663
|
+
// the label in each other site language
|
|
664
|
+
translations: z.record(z.string().max(60)).default({}),
|
|
665
|
+
// what an approved draft gets: this tag ('' = none), this calendar ('' = the default calendar)
|
|
666
|
+
tag: z.string().max(60).default(''),
|
|
667
|
+
calendarId: z.string().default(''),
|
|
668
|
+
});
|
|
669
|
+
export type RequestType = z.infer<typeof RequestType>;
|
|
670
|
+
export const RequestWords = z.object({ replyTime: z.string().max(60).default(''), notice: z.string().max(400).default('') });
|
|
671
|
+
export const RequestSettings = z.object({
|
|
672
|
+
// closed: the form says so (with the phone number) and new requests are refused
|
|
673
|
+
open: z.boolean().default(true),
|
|
674
|
+
types: z.array(RequestType).max(12).default([]),
|
|
675
|
+
// "We usually answer within {replyTime}" ("3 days"); '' leaves the sentence out
|
|
676
|
+
replyTime: z.string().max(60).default(''),
|
|
677
|
+
// the form's opening line ('' = the built-in "It isn't on the calendar until…")
|
|
678
|
+
notice: z.string().max(400).default(''),
|
|
679
|
+
// replyTime and notice in each other site language
|
|
680
|
+
translations: z.record(RequestWords).default({}),
|
|
681
|
+
// the office phone, shown when requests are closed and on "Request sent"
|
|
682
|
+
phone: z.string().max(40).default(''),
|
|
683
|
+
// who hears about new requests; [] = acms({ members.notify }), else the owners
|
|
684
|
+
notify: z.array(z.string().max(254)).max(20).default([]),
|
|
685
|
+
// staff on that list who linked a phone also get a push (with no details)
|
|
686
|
+
phones: z.boolean().default(false),
|
|
687
|
+
// new requests a day from the app and the website together; 0 = no cap
|
|
688
|
+
perDay: z.number().int().min(0).max(10000).default(100),
|
|
689
|
+
// requests are deleted this many days after the answer (or after they arrived, if nobody answered)
|
|
690
|
+
keepDays: z.number().int().min(7).max(1095).default(180),
|
|
691
|
+
});
|
|
692
|
+
export type RequestSettings = z.infer<typeof RequestSettings>;
|
|
693
|
+
|
|
654
694
|
export const CalendarSettings = z.object({
|
|
655
695
|
// Display-only fallback: event covers remain empty until an image is explicitly chosen.
|
|
656
696
|
eventFallbackImage: EventImageUrl.default(''),
|
|
@@ -663,6 +703,7 @@ export const CalendarSettings = z.object({
|
|
|
663
703
|
approvalRules: ApprovalRules.default({}),
|
|
664
704
|
// show bookings on the Schedule grid
|
|
665
705
|
showBookings: z.boolean().default(true),
|
|
706
|
+
requests: RequestSettings.default({}),
|
|
666
707
|
});
|
|
667
708
|
export type CalendarSettings = z.infer<typeof CalendarSettings>;
|
|
668
709
|
|
|
@@ -25,6 +25,7 @@ import { approveEventRequest, declineEventRequest, pendingRequests } from '../..
|
|
|
25
25
|
import { approveDateRequest, dateRequestClashes, dateRequestTimes, declineDateRequest, type MoveRequestData } from '../../../../lib/calendar/host-requests';
|
|
26
26
|
import { mergeEventDraft } from '../../../../lib/drafts';
|
|
27
27
|
import { ApiError } from '../../../../lib/api';
|
|
28
|
+
import { REQUESTS } from '../../../../lib/calendar/request-config';
|
|
28
29
|
|
|
29
30
|
// Schedule → List: upcoming first, grouped by the month each event next
|
|
30
31
|
// happens. Subscribed calendars appear as read-only rows with a "Make an
|
|
@@ -527,6 +528,7 @@ const subBits = [
|
|
|
527
528
|
))}
|
|
528
529
|
</section>
|
|
529
530
|
)}
|
|
531
|
+
{REQUESTS.enabled && <p class="hint">Every request, including those from the app and the website, is on <a href="/admin/calendar/requests">Requests</a>.</p>}
|
|
530
532
|
{(requestRows.length > 0 || dateRows.length > 0) && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
|
|
531
533
|
{canApprove && dateRows.map((r) => (
|
|
532
534
|
<Modal id={`decline-date-${r.id}`} title="Decline this date change">
|