@acms-subir/core 0.3.54 → 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 +36 -0
- package/cli/main.mjs +2 -1
- package/cli/migrations.generated.mjs +59 -6
- package/docs/calendar.md +104 -2
- package/docs/google-calendar.md +302 -0
- package/docs/mobile.md +3 -1
- package/docs/server.md +58 -0
- package/integration.mjs +38 -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/LegalSettings.tsx +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/google/client.ts +167 -0
- package/src/lib/calendar/google/connect.ts +161 -0
- package/src/lib/calendar/google/context.ts +50 -0
- package/src/lib/calendar/google/inbound.ts +493 -0
- package/src/lib/calendar/google/links.ts +139 -0
- package/src/lib/calendar/google/map.ts +543 -0
- package/src/lib/calendar/google/match.ts +103 -0
- package/src/lib/calendar/google/migration.ts +71 -0
- package/src/lib/calendar/google/outbound.ts +272 -0
- package/src/lib/calendar/google/service.ts +193 -0
- package/src/lib/calendar/google/store.ts +241 -0
- package/src/lib/calendar/google/types.ts +188 -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/calendar/schedule.ts +8 -8
- package/src/lib/display/board.ts +3 -1
- package/src/lib/email/service.ts +17 -2
- package/src/lib/email/templates.ts +8 -0
- package/src/lib/legal/admin.ts +1 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +7 -0
- package/src/lib/legal/text-en.ts +7 -0
- package/src/lib/legal/text-es.ts +7 -0
- package/src/lib/live/crypto.ts +7 -28
- 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/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +18 -9
- package/src/lib/repo/types.ts +6 -2
- package/src/lib/scheduler.ts +6 -0
- package/src/lib/schema.ts +43 -2
- package/src/lib/sealed.ts +46 -0
- package/src/pages/admin/calendar/events/[id].astro +42 -1
- package/src/pages/admin/calendar/events/index.astro +2 -0
- package/src/pages/admin/calendar/google/match.astro +160 -0
- package/src/pages/admin/calendar/google.astro +107 -0
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/admin/calendar/settings.astro +273 -4
- package/src/pages/admin/integrations.astro +6 -0
- package/src/pages/api/calendar/google/oauth/callback.ts +16 -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 +105 -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;
|
|
@@ -194,7 +194,7 @@ export async function undoMove(repo: ContentRepository, actor: MoveActor, undo:
|
|
|
194
194
|
// checks: callers (admin actions, site code through @acms-subir/core/server)
|
|
195
195
|
// decide who may do this.
|
|
196
196
|
|
|
197
|
-
async function setOccurrenceCancelled(repo: ContentRepository, id: string, key: string, cancel: boolean, hooks: SaveHooks, now = Date.now()): Promise<CalEvent> {
|
|
197
|
+
async function setOccurrenceCancelled(repo: ContentRepository, id: string, key: string, cancel: boolean, hooks: SaveHooks, now = Date.now(), opts: { quiet?: boolean } = {}): Promise<CalEvent> {
|
|
198
198
|
const e = await repo.getEvent(id);
|
|
199
199
|
if (!e) throw new ApiError(404, 'Event not found.');
|
|
200
200
|
// error.code as for moves (lib/calendar/moves.ts MoveRefusal)
|
|
@@ -210,22 +210,22 @@ async function setOccurrenceCancelled(repo: ContentRepository, id: string, key:
|
|
|
210
210
|
// a date is moved or cancelled, never both: cancelling a moved date drops its move (its RSVPs, at the new time, are cancelled below)
|
|
211
211
|
if (cancel && Object.hasOwn(e.movedDates, key)) {
|
|
212
212
|
const { [key]: _moved, ...moved } = e.movedDates;
|
|
213
|
-
await repo.setEventMovedDates(id, moved, cancelledDates);
|
|
214
|
-
} else await repo.setEventCancelledDates(id, cancelledDates);
|
|
213
|
+
await repo.setEventMovedDates(id, moved, cancelledDates, opts);
|
|
214
|
+
} else await repo.setEventCancelledDates(id, cancelledDates, opts);
|
|
215
215
|
if (cancel && await repo.cancelOccurrenceRsvps(id, o.start)) await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
|
|
216
216
|
const after = (await repo.getEvent(id))!;
|
|
217
217
|
await eventSideEffects(repo, hooks, after);
|
|
218
218
|
return after;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
/** Cancel one date of a repeating event, at once (also on a published event). Idempotent. */
|
|
222
|
-
export function cancelOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number): Promise<CalEvent> {
|
|
223
|
-
return setOccurrenceCancelled(repo, id, key, true, hooks, now);
|
|
221
|
+
/** Cancel one date of a repeating event, at once (also on a published event). Idempotent. quiet: no app notice (RSVP emails still go). */
|
|
222
|
+
export function cancelOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number, opts?: { quiet?: boolean }): Promise<CalEvent> {
|
|
223
|
+
return setOccurrenceCancelled(repo, id, key, true, hooks, now, opts);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
/** Undo cancelOccurrence for one date. Idempotent. */
|
|
227
|
-
export function restoreOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number): Promise<CalEvent> {
|
|
228
|
-
return setOccurrenceCancelled(repo, id, key, false, hooks, now);
|
|
227
|
+
export function restoreOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number, opts?: { quiet?: boolean }): Promise<CalEvent> {
|
|
228
|
+
return setOccurrenceCancelled(repo, id, key, false, hooks, now, opts);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
export async function duplicateEvent(repo: ContentRepository, id: string): Promise<CalEvent> {
|
package/src/lib/display/board.ts
CHANGED
|
@@ -18,7 +18,9 @@ import { renderBoard, type DisplayItem, type DisplayModel, type DisplayRoom } fr
|
|
|
18
18
|
import { displayStrings, isBookedWord } from './words';
|
|
19
19
|
import { qrSvg } from './http';
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// booked appointments from Google booking pages (lib/calendar/google/map.ts)
|
|
22
|
+
export { BOOKING_TAG as GOOGLE_BOOKING_TAG } from '../calendar/google/map';
|
|
23
|
+
import { BOOKING_TAG as GOOGLE_BOOKING_TAG } from '../calendar/google/map';
|
|
22
24
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
23
25
|
const isoOf = (p: { y: number; m: number; d: number }) => `${p.y}-${pad(p.m)}-${pad(p.d)}`;
|
|
24
26
|
|
package/src/lib/email/service.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
|
|
3
3
|
import { sendWithProvider } from './providers';
|
|
4
|
-
import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, phoneLinkedMail, EmailLinkError, type EventRequestMailKind } from './templates';
|
|
4
|
+
import { accountMail, canonicalOrigin, eventRequestMail, googleSyncAlertMail, guestCodeMail, phoneLinkedMail, EmailLinkError, type EventRequestMailKind } from './templates';
|
|
5
5
|
import { formatDate, siteLang } from '../dates';
|
|
6
6
|
import { I18N } from '../i18n';
|
|
7
7
|
import { bookingMail } from '../booking/service';
|
|
@@ -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);
|
|
@@ -73,6 +78,16 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
|
|
|
73
78
|
const url = staff ? `${origin}/admin/calendar/events/${e.id}` : `${origin}${MEMBERS.enabled ? memberEventsPath() : '/'}`;
|
|
74
79
|
return eventRequestMail(p.kind, job.recipient, settings.siteTitle, { title: e.title, when: formatDate(e.startAt, settings), by: by?.name ?? '', note: e.request?.note ?? '' }, p.reply, url);
|
|
75
80
|
}
|
|
81
|
+
if (job.kind === 'gcal-alert') {
|
|
82
|
+
// only while that link is still failing since the same time
|
|
83
|
+
const p = JSON.parse(job.payload) as { linkId: string; since: number };
|
|
84
|
+
const link = await repo.gcal.getLink(p.linkId);
|
|
85
|
+
if (!link || link.state !== 'active' || link.failingSince !== p.since) return null;
|
|
86
|
+
let since: string;
|
|
87
|
+
try { since = new Intl.DateTimeFormat(siteLang(settings), { dateStyle: 'medium', timeStyle: 'short', timeZone: settings.timezone || 'UTC' }).format(p.since); }
|
|
88
|
+
catch { since = new Date(p.since).toISOString(); }
|
|
89
|
+
return googleSyncAlertMail(job.recipient, settings.siteTitle, { google: link.googleName, error: link.error, since }, `${origin}/admin/calendar/settings#google`);
|
|
90
|
+
}
|
|
76
91
|
if (job.kind === 'welcome' || job.kind === 'password-changed') return accountMail(job.kind, job.recipient, settings.siteTitle, `${origin}/admin/login`);
|
|
77
92
|
if (job.kind === 'email-changed') return accountMail(job.kind, job.recipient, settings.siteTitle);
|
|
78
93
|
return null;
|
|
@@ -99,7 +114,7 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
99
114
|
}
|
|
100
115
|
await repo.email.pin(j,resolved.provider,resolved.fingerprint,config?.version || 0);
|
|
101
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; }
|
|
102
|
-
if (j.kind === 'booking' || j.kind === 'event-request') {
|
|
117
|
+
if (j.kind === 'booking' || j.kind === 'event-request' || j.kind === 'calendar-request') {
|
|
103
118
|
const payload = JSON.parse(j.payload);
|
|
104
119
|
if ((payload.kind === 'reminder' && !current.config.reminders) || (String(payload.kind).startsWith('staff-') && !current.config.staffNotifications)) {
|
|
105
120
|
await repo.email.finish(j,{state:'cancelled',error:'Optional notification disabled.'}); continue;
|
|
@@ -38,6 +38,14 @@ export function eventRequestMail(kind: EventRequestMailKind, to: string, site: s
|
|
|
38
38
|
return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(extra ? ['', extra] : []), '', `${copy[2]}: ${url}`].join('\n'),
|
|
39
39
|
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:22px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${extra ? `<p style="background:#efebe4;border-radius:8px;padding:10px 12px">${escape(extra)}</p>` : ''}<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p></div></body></html>` };
|
|
40
40
|
}
|
|
41
|
+
/** staff: a Google Calendar link has been failing for an hour (lib/calendar/google/service.ts) */
|
|
42
|
+
export function googleSyncAlertMail(to: string, site: string, link: { google: string; error: string; since: string }, url: string): Mail {
|
|
43
|
+
const subject = `Google Calendar sync stopped: ${link.google}`;
|
|
44
|
+
const intro = `The site hasn’t been able to sync with “${link.google}” since ${link.since}. Changes on either side wait until it works again; nothing is lost.`;
|
|
45
|
+
const reason = link.error ? `Google said: ${link.error}` : '';
|
|
46
|
+
return { to, subject: `${subject} · ${site}`, text: [subject, '', intro, ...(reason ? ['', reason] : []), '', `Check it in Calendar settings: ${url}`].join('\n'),
|
|
47
|
+
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:22px">${escape(subject)}</h1><p>${escape(intro)}</p>${reason ? `<p style="background:#efebe4;border-radius:8px;padding:10px 12px">${escape(reason)}</p>` : ''}<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">Open Calendar settings</a></p></div></body></html>` };
|
|
48
|
+
}
|
|
41
49
|
/** a guest's app sign-in code; no link, so it works on any device */
|
|
42
50
|
export function guestCodeMail(to: string, site: string, code: string): Mail {
|
|
43
51
|
const intro = `Enter this code in the ${site} app to see your bookings. It expires in 10 minutes. If you did not ask for it, ignore this message.`;
|
package/src/lib/legal/admin.ts
CHANGED
|
@@ -40,7 +40,7 @@ export async function legalAdminState(repo: ContentRepository, url: URL, locals?
|
|
|
40
40
|
updated: LEGAL.enabled ? { privacy: await policyUpdated(repo, 'privacy', facts, settings), terms: await policyUpdated(repo, 'terms', facts, settings) } : null,
|
|
41
41
|
covers: {
|
|
42
42
|
calendar: facts.calendar, bookings: facts.bookings, guestSignIn: facts.guestSignIn, push: facts.push.enabled, auth: facts.auth,
|
|
43
|
-
email: facts.email, maps: facts.maps, live: facts.live, analytics: facts.analytics, ai: facts.ai,
|
|
43
|
+
email: facts.email, maps: facts.maps, live: facts.live, googleCalendar: facts.googleCalendar, analytics: facts.analytics, ai: facts.ai,
|
|
44
44
|
},
|
|
45
45
|
warnings,
|
|
46
46
|
answers: appPrivacyAnswers(facts, link('privacy') ?? `${origin}/privacy`),
|
|
@@ -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';
|
|
@@ -40,8 +41,12 @@ export interface LegalFacts {
|
|
|
40
41
|
/** maps provider name, or '' */
|
|
41
42
|
maps: string;
|
|
42
43
|
live: boolean;
|
|
44
|
+
/** a Google account is connected for the two-way Google Calendar sync (lib/calendar/google/) */
|
|
45
|
+
googleCalendar: boolean;
|
|
43
46
|
/** acms({ announcements }): urgent ones reach every device; read and closed state stays in the app */
|
|
44
47
|
announcements: boolean;
|
|
48
|
+
/** acms({ requests }): event requests without an account, kept keepDays after the answer */
|
|
49
|
+
requests: { enabled: boolean; keepDays: number };
|
|
45
50
|
analytics: { umami: boolean; replay: boolean; meta: boolean; tiktok: boolean };
|
|
46
51
|
ai: boolean;
|
|
47
52
|
offsiteBackups: boolean;
|
|
@@ -82,7 +87,9 @@ export async function legalFacts(repo: ContentRepository, settings: Settings, lo
|
|
|
82
87
|
email: email && !email.error && email.provider !== 'none' && email.config.enabled ? EMAIL_NAMES[email.provider] ?? email.provider : '',
|
|
83
88
|
maps: (() => { const m = mapsStatus(locals); return m.enabled ? m.label.replace(/ \(self-hosted\)$/, '') : ''; })(),
|
|
84
89
|
live: settings.live.mode !== 'off',
|
|
90
|
+
googleCalendar: CALENDAR.enabled && await repo.gcal.account().then((a) => !!a && a.status !== 'setup').catch(() => false),
|
|
85
91
|
announcements: ANNOUNCEMENTS.enabled,
|
|
92
|
+
requests: { enabled: REQUESTS.enabled, keepDays: settings.calendar.requests.keepDays },
|
|
86
93
|
analytics: {
|
|
87
94
|
umami: a.umami.enabled && !!a.umami.websiteId,
|
|
88
95
|
replay: a.umami.enabled && !!a.umami.websiteId && a.umami.replay.enabled,
|
package/src/lib/legal/text-en.ts
CHANGED
|
@@ -14,6 +14,7 @@ function processors(f: LegalFacts): string[] {
|
|
|
14
14
|
...(f.push.enabled ? ['Expo, Apple and Google (app notifications)'] : []),
|
|
15
15
|
...(f.auth === 'clerk' ? ['Clerk (sign-in)'] : []),
|
|
16
16
|
...(f.maps ? [`${f.maps} (maps)`] : []),
|
|
17
|
+
...(f.googleCalendar ? ['Google (Google Calendar)'] : []),
|
|
17
18
|
...(f.analytics.umami ? ['Umami (visit statistics)'] : []),
|
|
18
19
|
...(f.ai ? ['OpenAI or another AI service we choose (writing tools used by staff)'] : []),
|
|
19
20
|
...(f.offsiteBackups ? ['our backup storage provider'] : []),
|
|
@@ -48,6 +49,9 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
|
48
49
|
s.push({ id: 'forms', title: 'Forms', text: [
|
|
49
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.' : ''}`,
|
|
50
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
|
+
] });
|
|
51
55
|
if (f.guestSignIn) s.push({ id: 'guest', title: 'Signing in to see your bookings', text: [
|
|
52
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.',
|
|
53
57
|
] });
|
|
@@ -61,6 +65,9 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
|
61
65
|
if (f.maps) s.push({ id: 'maps', title: 'Maps', text: [
|
|
62
66
|
`When staff enter an event’s address, we look it up with ${f.maps}. Your browser doesn’t contact the map service: map images are stored on this site. Directions links open Google Maps only if you choose them.`,
|
|
63
67
|
] });
|
|
68
|
+
if (f.googleCalendar) s.push({ id: 'google-calendar', title: 'Google Calendar', text: [
|
|
69
|
+
'We keep a copy of our calendar in our own Google Calendar account, and changes made there come back to this site. That copy holds what our calendar shows: event titles, times, places and descriptions. It never includes your RSVPs, your name or what you tell us when you sign up for an event, and Google sends no invitations. Appointments booked through our Google booking pages show on this site only as “Booked”, without names. Google applies its own privacy policy to its copy.',
|
|
70
|
+
] });
|
|
64
71
|
if (f.live) s.push({ id: 'live', title: 'Live video', text: [
|
|
65
72
|
'When we stream, the site shows our broadcast from YouTube or Twitch. Until you press play, your browser only contacts this site: preview images come through it. Pressing play, or opening the broadcast on YouTube or Twitch, loads that platform, which applies its own privacy policy.',
|
|
66
73
|
] });
|
package/src/lib/legal/text-es.ts
CHANGED
|
@@ -14,6 +14,7 @@ function processors(f: LegalFacts): string[] {
|
|
|
14
14
|
...(f.push.enabled ? ['Expo, Apple y Google (notificaciones de la app)'] : []),
|
|
15
15
|
...(f.auth === 'clerk' ? ['Clerk (inicio de sesión)'] : []),
|
|
16
16
|
...(f.maps ? [`${f.maps} (mapas)`] : []),
|
|
17
|
+
...(f.googleCalendar ? ['Google (Google Calendar)'] : []),
|
|
17
18
|
...(f.analytics.umami ? ['Umami (estadísticas de visitas)'] : []),
|
|
18
19
|
...(f.ai ? ['OpenAI u otro servicio de inteligencia artificial que elijamos (herramientas de redacción que usa el personal)'] : []),
|
|
19
20
|
...(f.offsiteBackups ? ['nuestro proveedor de almacenamiento de respaldos'] : []),
|
|
@@ -49,6 +50,9 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
|
49
50
|
s.push({ id: 'forms', title: 'Formularios', text: [
|
|
50
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.' : ''}`,
|
|
51
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
|
+
] });
|
|
52
56
|
if (f.guestSignIn) s.push({ id: 'guest', title: 'Inicio de sesión para ver sus reservaciones', text: [
|
|
53
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.',
|
|
54
58
|
] });
|
|
@@ -62,6 +66,9 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
|
62
66
|
if (f.maps) s.push({ id: 'maps', title: 'Mapas', text: [
|
|
63
67
|
`Cuando el personal escribe la dirección de un evento, la buscamos con ${f.maps}. Su navegador no se conecta con el servicio de mapas: las imágenes de los mapas se guardan en este sitio. Los enlaces para llegar abren Google Maps solo si usted los elige.`,
|
|
64
68
|
] });
|
|
69
|
+
if (f.googleCalendar) s.push({ id: 'google-calendar', title: 'Google Calendar', text: [
|
|
70
|
+
'Guardamos una copia de nuestro calendario en nuestra propia cuenta de Google Calendar, y los cambios que se hacen allí regresan a este sitio. Esa copia contiene lo que muestra nuestro calendario: títulos, horarios, lugares y descripciones de los eventos. Nunca incluye sus confirmaciones de asistencia, su nombre ni lo que nos dice al inscribirse en un evento, y Google no envía invitaciones. Las citas reservadas en nuestras páginas de reserva de Google aparecen en este sitio solo como “Reservado”, sin nombres. Google aplica su propia política de privacidad a su copia.',
|
|
71
|
+
] });
|
|
65
72
|
if (f.live) s.push({ id: 'live', title: 'Video en vivo', text: [
|
|
66
73
|
'Cuando transmitimos, el sitio muestra nuestra transmisión de YouTube o Twitch. Hasta que usted presiona reproducir, su navegador solo se conecta con este sitio: las imágenes de vista previa pasan por él. Al presionar reproducir, o al abrir la transmisión en YouTube o Twitch, se carga esa plataforma, que aplica su propia política de privacidad.',
|
|
67
74
|
] });
|
package/src/lib/live/crypto.ts
CHANGED
|
@@ -1,28 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return createHash('sha256').update(`acms:live:v1:${secret}`).digest();
|
|
9
|
-
}
|
|
10
|
-
export const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
11
|
-
export const randomToken = () => randomBytes(32).toString('base64url');
|
|
12
|
-
export function encryptionReady(locals?: unknown) { try { key(locals); return true; } catch { return false; } }
|
|
13
|
-
export function encrypt(value: unknown, context: string, locals?: unknown) {
|
|
14
|
-
const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key(locals), iv);
|
|
15
|
-
cipher.setAAD(Buffer.from(`acms:live:${context}`));
|
|
16
|
-
const data = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
|
|
17
|
-
return [iv, cipher.getAuthTag(), data].map(v => v.toString('base64url')).join('.');
|
|
18
|
-
}
|
|
19
|
-
export function decrypt<T>(encrypted: string, context: string, locals?: unknown): T {
|
|
20
|
-
try {
|
|
21
|
-
const parts = encrypted.split('.');
|
|
22
|
-
if (parts.length !== 3) throw new Error();
|
|
23
|
-
const [iv, tag, data] = parts.map(v => Buffer.from(v, 'base64url'));
|
|
24
|
-
const cipher = createDecipheriv('aes-256-gcm', key(locals), iv);
|
|
25
|
-
cipher.setAAD(Buffer.from(`acms:live:${context}`)); cipher.setAuthTag(tag);
|
|
26
|
-
return JSON.parse(Buffer.concat([cipher.update(data), cipher.final()]).toString('utf8'));
|
|
27
|
-
} catch { throw new ApiError(503, 'Saved live credentials cannot be decrypted. Re-enter the credentials and reconnect.'); }
|
|
28
|
-
}
|
|
1
|
+
import { sealer } from '../sealed';
|
|
2
|
+
// Live video credentials (lib/sealed.ts): the same key and wire format as before it was shared.
|
|
3
|
+
const live = sealer('live', 'live credentials');
|
|
4
|
+
export { hash, randomToken } from '../sealed';
|
|
5
|
+
export const encryptionReady = (locals?: unknown) => live.ready(locals);
|
|
6
|
+
export const encrypt = (value: unknown, context: string, locals?: unknown) => live.encrypt(value, context, locals);
|
|
7
|
+
export const decrypt = <T>(encrypted: string, context: string, locals?: unknown): T => live.decrypt<T>(encrypted, context, locals);
|
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 };
|