@acms-subir/core 0.3.40 → 0.3.41
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 +12 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/mobile.md +2 -1
- package/integration.mjs +2 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/rsvp/recipe.json +2 -2
- package/package.json +1 -1
- package/src/actions/index.ts +1 -1
- package/src/components/EventPage.astro +16 -8
- package/src/components/editor/EventPanel.tsx +2 -2
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +5 -0
- package/src/components/editor/event/registration.ts +1 -1
- package/src/lib/booking/agent.ts +1 -1
- package/src/lib/booking/emails.ts +20 -5
- package/src/lib/booking/service.ts +148 -14
- package/src/lib/calendar/rsvp-words.ts +69 -8
- package/src/lib/content-service.ts +11 -6
- package/src/lib/drafts.ts +2 -2
- package/src/lib/editor/bookings.ts +5 -3
- package/src/lib/editor/documents.ts +3 -2
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +26 -2
- package/src/lib/email/store.ts +2 -2
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/text-en.ts +1 -1
- package/src/lib/legal/text-es.ts +1 -1
- package/src/lib/mobile/bookings.ts +4 -3
- package/src/lib/mobile/manage.ts +17 -3
- package/src/lib/mobile/openapi.ts +4 -2
- package/src/lib/mobile/rsvp.ts +10 -5
- package/src/lib/mobile/schemas.ts +10 -3
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +53 -13
- package/src/lib/repo/types.ts +11 -4
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +10 -2
- package/src/pages/admin/calendar/bookings.astro +57 -13
- package/src/pages/api/calendar/bookings/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -2
- package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
- package/src/pages/book/manage/[token].astro +57 -15
- package/src/pages/events/[slug].astro +3 -2
- package/src/styles/admin.css +3 -1
- package/src/styles/site.css +10 -1
- package/src/theme-sdk/index.ts +1 -1
|
@@ -161,9 +161,10 @@ export const EventFields = z.object({ ...common, excerpt: text(1000), tags: z.ar
|
|
|
161
161
|
cta: z.object({ label: text(120), href: text(1000), style: z.enum(['primary', 'outline']) }).strict(),
|
|
162
162
|
calendarIds: z.array(text(100)).max(50), fields: z.record(text(2000)),
|
|
163
163
|
// closesAt: wall time in the event's zone ('YYYY-MM-DDTHH:mm'); closeBefore: minutes before each date;
|
|
164
|
-
// notifyStaff: the hosts get an email about each new or cancelled RSVP
|
|
164
|
+
// notifyStaff: the hosts get an email about each new or cancelled RSVP; waitlist: a full date
|
|
165
|
+
// takes places in line (with a capacity). Left out = kept
|
|
165
166
|
rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50),
|
|
166
|
-
closesAt: text(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), notifyStaff: z.boolean().optional(), form: FormDefinition.optional() }).strict(),
|
|
167
|
+
closesAt: text(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), notifyStaff: z.boolean().optional(), waitlist: z.boolean().optional(), form: FormDefinition.optional() }).strict(),
|
|
167
168
|
}).partial().strict();
|
|
168
169
|
export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
|
|
169
170
|
|
|
@@ -47,7 +47,7 @@ export const EditorDocument = EditorDocumentSummary.extend({
|
|
|
47
47
|
event: z.object({
|
|
48
48
|
start: str, end: str, allDay: bool, timezone: str, rrule: str, exdates: z.array(str), cancelled: bool,
|
|
49
49
|
location: z.object({ name: str, address: str, url: str, lat: num.nullable(), lng: num.nullable(), placeId: str, showMap: bool, mapImage: str, mapImageDark: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
|
|
50
|
-
calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, closesAt: str.nullable(), closeBefore: num, notifyStaff: bool, form: FormDefinition.optional() }),
|
|
50
|
+
calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, closesAt: str.nullable(), closeBefore: num, notifyStaff: bool, waitlist: bool, form: FormDefinition.optional() }),
|
|
51
51
|
}).nullable(),
|
|
52
52
|
languages: z.array(str),
|
|
53
53
|
});
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// (book/manage; an RSVP's page words are in calendar/rsvp-words.ts). Emails
|
|
4
4
|
// say usted in Spanish, like the privacy notice; the website's pages say tú,
|
|
5
5
|
// like the RSVP words. Plain strings with {name}, {title}, {when}, {ref},
|
|
6
|
-
// {url} and {hours} slots
|
|
6
|
+
// {url} and {hours} slots, and {position} and {until} for the waitlist
|
|
7
|
+
// (live/words.ts `fill`). `rsvp` rewords a few
|
|
7
8
|
// messages for a seat at an event (English says "booking" for both). Staff
|
|
8
9
|
// emails use the site's main language. Other languages fall back to English.
|
|
9
|
-
|
|
10
|
+
// waitlisted, offered, left: an RSVP's waitlist (events only), worded for it
|
|
11
|
+
export type GuestMail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'updated' | 'rescheduled' | 'reminder' | 'link' | 'waitlisted' | 'offered' | 'left';
|
|
10
12
|
export interface MailCopy { subject: string; heading: string; intro: string }
|
|
11
13
|
|
|
12
14
|
export interface BookingStrings {
|
|
@@ -14,6 +16,8 @@ export interface BookingStrings {
|
|
|
14
16
|
/** the button to the booking's own page */
|
|
15
17
|
manage: string;
|
|
16
18
|
rsvp: { manage: string } & Partial<Record<GuestMail, Partial<MailCopy>>>;
|
|
19
|
+
/** the waitlist emails' buttons: the place in line (where they can leave it), and taking a held spot */
|
|
20
|
+
waitlist: { manage: string; confirm: string };
|
|
17
21
|
/** the details table */
|
|
18
22
|
what: string;
|
|
19
23
|
when: string;
|
|
@@ -71,8 +75,12 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
71
75
|
rescheduled: { subject: 'New time: {title} — {when}', heading: 'Booking moved', intro: 'Your booking has a new time. Import the attached calendar file to update your calendar.' },
|
|
72
76
|
reminder: { subject: 'Reminder: {title} — {when}', heading: 'Coming up soon', intro: 'A quick reminder about your booking.' },
|
|
73
77
|
link: { subject: 'Your booking: {title}', heading: 'You already have this booking', intro: 'We got another request for this date with your email address, so nothing changed. To see or change your booking, use the button below.' },
|
|
78
|
+
waitlisted: { subject: 'You’re on the waitlist: {title}', heading: 'You’re on the waitlist', intro: 'You’re #{position} in line. If a spot opens, we’ll email you and hold it for {hours} hours.' },
|
|
79
|
+
offered: { subject: 'A spot opened: {title}', heading: 'A spot opened for {title}', intro: 'We’re holding it for you until {until}. Confirm it before then, or it goes to the next person in line.' },
|
|
80
|
+
left: { subject: 'Off the waitlist: {title}', heading: 'You’re off the waitlist', intro: 'You’re no longer on the waitlist for this date.' },
|
|
74
81
|
},
|
|
75
82
|
manage: 'View or change your booking', rsvp: { manage: 'View or change your booking' },
|
|
83
|
+
waitlist: { manage: 'See your place or leave the waitlist', confirm: 'Confirm my spot' },
|
|
76
84
|
what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
|
|
77
85
|
lateDecline: 'We weren’t able to confirm this booking in time. Please pick another time.',
|
|
78
86
|
staff: { pending: 'New request to approve', booked: 'New booking', review: 'Review in the admin', open: 'Open in the admin', pendingSubject: 'Approve: {title} — {when}', bookedSubject: 'New booking: {title} — {when}' },
|
|
@@ -103,6 +111,9 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
103
111
|
rescheduled: { subject: 'Nuevo horario: {title} — {when}', heading: 'Su reservación cambió de horario', intro: 'Su reservación tiene un nuevo horario. Importe el archivo de calendario adjunto para actualizar su calendario.' },
|
|
104
112
|
reminder: { subject: 'Recordatorio: {title} — {when}', heading: 'Ya casi es la fecha', intro: 'Un breve recordatorio de su reservación.' },
|
|
105
113
|
link: { subject: 'Su reservación: {title}', heading: 'Ya tiene esta reservación', intro: 'Recibimos otra solicitud para este horario con su correo, así que no cambió nada. Para ver o cambiar su reservación, use el botón de abajo.' },
|
|
114
|
+
waitlisted: { subject: 'Está en la lista de espera: {title}', heading: 'Está en la lista de espera', intro: 'Tiene el lugar #{position} en la fila. Si se libera un lugar, le escribiremos y se lo apartaremos {hours} horas.' },
|
|
115
|
+
offered: { subject: 'Se liberó un lugar: {title}', heading: 'Se liberó un lugar para {title}', intro: 'Se lo apartamos hasta el {until}. Confírmelo antes, o pasará a la siguiente persona en la fila.' },
|
|
116
|
+
left: { subject: 'Fuera de la lista de espera: {title}', heading: 'Ya no está en la lista de espera', intro: 'Ya no está en la lista de espera para esta fecha.' },
|
|
106
117
|
},
|
|
107
118
|
manage: 'Ver o cambiar su reservación',
|
|
108
119
|
rsvp: {
|
|
@@ -114,6 +125,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
114
125
|
reminder: { intro: 'Un breve recordatorio: tiene un lugar apartado.' },
|
|
115
126
|
link: { subject: 'Su confirmación para {title}', heading: 'Ya tiene una confirmación', intro: 'Recibimos otra confirmación para esta fecha con su correo, así que no cambió nada. Para cambiar la suya, o agregar lugares, use el botón de abajo.' },
|
|
116
127
|
},
|
|
128
|
+
waitlist: { manage: 'Ver su lugar o salir de la lista', confirm: 'Confirmar mi lugar' },
|
|
117
129
|
what: 'Qué', when: 'Cuándo', where: 'Dónde', seats: 'Lugares', reference: 'Referencia {ref}', icsManage: 'Ver o cambiar: {url}',
|
|
118
130
|
lateDecline: 'No pudimos confirmar esta reservación a tiempo. Por favor, elija otro horario.',
|
|
119
131
|
staff: { pending: 'Nueva solicitud por aprobar', booked: 'Nueva reservación', review: 'Revisar en el panel', open: 'Abrir en el panel', pendingSubject: 'Por aprobar: {title} — {when}', bookedSubject: 'Nueva reservación: {title} — {when}' },
|
|
@@ -144,6 +156,9 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
144
156
|
rescheduled: { subject: 'Nouvel horaire : {title} — {when}', heading: 'Réservation déplacée', intro: 'Votre réservation a un nouvel horaire. Importez le fichier de calendrier joint pour mettre à jour votre calendrier.' },
|
|
145
157
|
reminder: { subject: 'Rappel : {title} — {when}', heading: 'C’est bientôt', intro: 'Un petit rappel de votre réservation.' },
|
|
146
158
|
link: { subject: 'Votre réservation : {title}', heading: 'Vous avez déjà cette réservation', intro: 'Nous avons reçu une autre demande pour ce créneau avec votre adresse e-mail, donc rien n’a changé. Pour voir ou modifier votre réservation, utilisez le bouton ci-dessous.' },
|
|
159
|
+
waitlisted: { subject: 'Vous êtes sur la liste d’attente : {title}', heading: 'Vous êtes sur la liste d’attente', intro: 'Vous êtes n° {position} dans la file. Si une place se libère, nous vous écrirons et vous la garderons {hours} heures.' },
|
|
160
|
+
offered: { subject: 'Une place s’est libérée : {title}', heading: 'Une place s’est libérée pour {title}', intro: 'Nous vous la gardons jusqu’au {until}. Confirmez-la d’ici là, sinon elle ira à la personne suivante dans la file.' },
|
|
161
|
+
left: { subject: 'Hors de la liste d’attente : {title}', heading: 'Vous n’êtes plus sur la liste d’attente', intro: 'Vous n’êtes plus sur la liste d’attente pour cette date.' },
|
|
147
162
|
},
|
|
148
163
|
manage: 'Voir ou modifier votre réservation',
|
|
149
164
|
rsvp: {
|
|
@@ -155,6 +170,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
155
170
|
reminder: { intro: 'Un petit rappel de votre inscription.' },
|
|
156
171
|
link: { subject: 'Votre inscription : {title}', heading: 'Vous avez déjà une inscription', intro: 'Nous avons reçu une autre inscription pour cette date avec votre adresse e-mail, donc rien n’a changé. Pour modifier la vôtre ou ajouter des places, utilisez le bouton ci-dessous.' },
|
|
157
172
|
},
|
|
173
|
+
waitlist: { manage: 'Voir votre place ou quitter la liste', confirm: 'Confirmer ma place' },
|
|
158
174
|
what: 'Quoi', when: 'Quand', where: 'Où', seats: 'Places', reference: 'Référence {ref}', icsManage: 'Gérer : {url}',
|
|
159
175
|
lateDecline: 'Nous n’avons pas pu confirmer cette réservation à temps. Veuillez choisir un autre horaire.',
|
|
160
176
|
staff: { pending: 'Nouvelle demande à valider', booked: 'Nouvelle réservation', review: 'Examiner dans l’administration', open: 'Ouvrir dans l’administration', pendingSubject: 'À valider : {title} — {when}', bookedSubject: 'Nouvelle réservation : {title} — {when}' },
|
|
@@ -185,6 +201,9 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
185
201
|
rescheduled: { subject: 'Neuer Termin: {title} — {when}', heading: 'Buchung verschoben', intro: 'Ihre Buchung hat einen neuen Termin. Importieren Sie die angehängte Kalenderdatei, um Ihren Kalender zu aktualisieren.' },
|
|
186
202
|
reminder: { subject: 'Erinnerung: {title} — {when}', heading: 'Bald ist es so weit', intro: 'Eine kurze Erinnerung an Ihre Buchung.' },
|
|
187
203
|
link: { subject: 'Ihre Buchung: {title}', heading: 'Sie haben diese Buchung schon', intro: 'Wir haben eine weitere Anfrage für diesen Termin mit Ihrer E-Mail-Adresse erhalten, es hat sich also nichts geändert. Über den Button unten sehen oder ändern Sie Ihre Buchung.' },
|
|
204
|
+
waitlisted: { subject: 'Sie stehen auf der Warteliste: {title}', heading: 'Sie stehen auf der Warteliste', intro: 'Sie sind Nr. {position} auf der Warteliste. Wird ein Platz frei, schreiben wir Ihnen und halten ihn {hours} Stunden für Sie frei.' },
|
|
205
|
+
offered: { subject: 'Ein Platz ist frei geworden: {title}', heading: 'Ein Platz für {title} ist frei geworden', intro: 'Wir halten ihn bis {until} für Sie frei. Bestätigen Sie ihn bis dahin, sonst geht er an die nächste Person auf der Warteliste.' },
|
|
206
|
+
left: { subject: 'Nicht mehr auf der Warteliste: {title}', heading: 'Sie stehen nicht mehr auf der Warteliste', intro: 'Sie stehen für diesen Termin nicht mehr auf der Warteliste.' },
|
|
188
207
|
},
|
|
189
208
|
manage: 'Buchung ansehen oder ändern',
|
|
190
209
|
rsvp: {
|
|
@@ -196,6 +215,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
196
215
|
reminder: { intro: 'Eine kurze Erinnerung an Ihre Anmeldung.' },
|
|
197
216
|
link: { subject: 'Ihre Anmeldung: {title}', heading: 'Sie sind schon angemeldet', intro: 'Wir haben eine weitere Anmeldung für diesen Termin mit Ihrer E-Mail-Adresse erhalten, es hat sich also nichts geändert. Über den Button unten ändern Sie Ihre Anmeldung oder fügen Plätze hinzu.' },
|
|
198
217
|
},
|
|
218
|
+
waitlist: { manage: 'Platz ansehen oder Warteliste verlassen', confirm: 'Platz bestätigen' },
|
|
199
219
|
what: 'Was', when: 'Wann', where: 'Wo', seats: 'Plätze', reference: 'Referenz {ref}', icsManage: 'Verwalten: {url}',
|
|
200
220
|
lateDecline: 'Wir konnten diese Buchung nicht rechtzeitig bestätigen. Bitte wählen Sie einen anderen Termin.',
|
|
201
221
|
staff: { pending: 'Neue Anfrage zur Freigabe', booked: 'Neue Buchung', review: 'In der Verwaltung prüfen', open: 'In der Verwaltung öffnen', pendingSubject: 'Freigeben: {title} — {when}', bookedSubject: 'Neue Buchung: {title} — {when}' },
|
|
@@ -226,6 +246,9 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
226
246
|
rescheduled: { subject: 'Novo horário: {title} — {when}', heading: 'Reserva remarcada', intro: 'Sua reserva tem um novo horário. Importe o arquivo de calendário anexo para atualizar seu calendário.' },
|
|
227
247
|
reminder: { subject: 'Lembrete: {title} — {when}', heading: 'Está chegando', intro: 'Um lembrete rápido sobre sua reserva.' },
|
|
228
248
|
link: { subject: 'Sua reserva: {title}', heading: 'Você já tem esta reserva', intro: 'Recebemos outro pedido para este horário com seu e-mail, então nada mudou. Para ver ou alterar sua reserva, use o botão abaixo.' },
|
|
249
|
+
waitlisted: { subject: 'Você está na lista de espera: {title}', heading: 'Você está na lista de espera', intro: 'Você é o nº {position} da fila. Se uma vaga abrir, vamos avisar por e-mail e guardá-la por {hours} horas.' },
|
|
250
|
+
offered: { subject: 'Abriu uma vaga: {title}', heading: 'Abriu uma vaga em {title}', intro: 'Vamos guardá-la para você até {until}. Confirme antes disso, ou ela passa para a próxima pessoa da fila.' },
|
|
251
|
+
left: { subject: 'Fora da lista de espera: {title}', heading: 'Você não está mais na lista de espera', intro: 'Você não está mais na lista de espera desta data.' },
|
|
229
252
|
},
|
|
230
253
|
manage: 'Ver ou alterar sua reserva',
|
|
231
254
|
rsvp: {
|
|
@@ -237,6 +260,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
237
260
|
reminder: { intro: 'Um lembrete rápido sobre sua inscrição.' },
|
|
238
261
|
link: { subject: 'Sua inscrição em {title}', heading: 'Você já tem uma inscrição', intro: 'Recebemos outra inscrição para esta data com seu e-mail, então nada mudou. Para alterar a sua, ou adicionar vagas, use o botão abaixo.' },
|
|
239
262
|
},
|
|
263
|
+
waitlist: { manage: 'Ver sua posição ou sair da lista', confirm: 'Confirmar minha vaga' },
|
|
240
264
|
what: 'O quê', when: 'Quando', where: 'Onde', seats: 'Vagas', reference: 'Referência {ref}', icsManage: 'Gerenciar: {url}',
|
|
241
265
|
lateDecline: 'Não conseguimos confirmar esta reserva a tempo. Por favor, escolha outro horário.',
|
|
242
266
|
staff: { pending: 'Novo pedido para aprovar', booked: 'Nova reserva', review: 'Revisar no painel', open: 'Abrir no painel', pendingSubject: 'Aprovar: {title} — {when}', bookedSubject: 'Nova reserva: {title} — {when}' },
|
package/src/lib/email/store.ts
CHANGED
|
@@ -27,7 +27,7 @@ export function bookingJobs(id: string, kind: string, version?: number, staff =
|
|
|
27
27
|
}
|
|
28
28
|
// Staff hear about an RSVP (new, or cancelled by the person) at the addresses
|
|
29
29
|
// the caller picked, in the same batch and only if the change landed.
|
|
30
|
-
// `taken` is the date's seat count right after it.
|
|
30
|
+
// `taken` is the date's seat count right after it (held waitlist spots too).
|
|
31
31
|
export function rsvpStaffJobs(id: string, kind: 'staff-rsvp' | 'staff-rsvp-cancelled', notice: RsvpStaffNotice | undefined, version: number, mutation = ''): SqlStatement[] {
|
|
32
32
|
if (!notice?.to.length) return [];
|
|
33
33
|
const now = Date.now();
|
|
@@ -35,7 +35,7 @@ export function rsvpStaffJobs(id: string, kind: 'staff-rsvp' | 'staff-rsvp-cance
|
|
|
35
35
|
sql: `INSERT OR IGNORE INTO email_jobs (id,dedupe,kind,recipient,payload,config_version,next_at,expires_at,created_at,updated_at)
|
|
36
36
|
SELECT lower(hex(randomblob(16))), b.id || ':' || b.notification_version || ':' || ? || ':' || lower(j.value), 'booking', lower(j.value),
|
|
37
37
|
json_object('bookingId',b.id,'version',b.notification_version,'kind',?,'why',?,'via',?,
|
|
38
|
-
'taken',(SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=b.event_id AND start_at=b.start_at AND status IN ('pending','confirmed'))),
|
|
38
|
+
'taken',(SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=b.event_id AND start_at=b.start_at AND status IN ('pending','confirmed','offered'))),
|
|
39
39
|
COALESCE((SELECT MAX(version) FROM email_configs),0), ?, NULL, ?, ?
|
|
40
40
|
FROM bookings b, json_each(?) j WHERE b.id = ? AND b.kind = 'rsvp' AND j.value != '' AND b.notification_version = ? ${mutation ? 'AND b.email_mutation = ?' : ''}`,
|
|
41
41
|
args: [kind, kind, notice.why, notice.via, now, now, now, JSON.stringify(notice.to), id, version, ...(mutation ? [mutation] : [])],
|
package/src/lib/forms/events.ts
CHANGED
|
@@ -17,7 +17,9 @@ export async function prepareLegacyEventForm(event: CalEvent, start: number, dat
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// locale: the page's language, so the RSVP's emails and links speak it
|
|
20
|
+
// locale: the page's language, so the RSVP's emails and links speak it. A
|
|
21
|
+
// form with waitlist=1 (a full date's "Join the waitlist") takes a place in
|
|
22
|
+
// line when there's no room
|
|
21
23
|
export async function submitEventForm(repo: ContentRepository, event: CalEvent, occurrence: { start: number; end: number }, data: FormData, requestId: string, flow: Parameters<typeof createRsvp>[2], locale = '') {
|
|
22
24
|
if (event.status !== 'published') throw new ApiError(404, 'Event not found.');
|
|
23
25
|
if (Number(data.get('occurrence')) !== occurrence.start) throw new ApiError(409, 'The event time has changed. Review the date and try again.');
|
|
@@ -37,6 +39,6 @@ export async function submitEventForm(repo: ContentRepository, event: CalEvent,
|
|
|
37
39
|
const existing = await replay(); if (existing) return existing;
|
|
38
40
|
await collectResponse(definition, booker.answers, booker.formVersion);
|
|
39
41
|
// the language isn't part of the retry check: it doesn't change what was asked for
|
|
40
|
-
try { return await createRsvp(repo, { event, ...occurrence, seats, booker: { ...booker, locale } }, { ...flow, idempotency }); }
|
|
42
|
+
try { return await createRsvp(repo, { event, ...occurrence, seats, booker: { ...booker, locale }, waitlist: data.get('waitlist') === '1' }, { ...flow, idempotency }); }
|
|
41
43
|
catch (e) { const existing = await replay(); if (existing) return existing; throw e; }
|
|
42
44
|
}
|
|
@@ -10,8 +10,8 @@ export interface AppPrivacyAnswers { rows: StoreRow[]; tracking: string; notes:
|
|
|
10
10
|
|
|
11
11
|
export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacyAnswers {
|
|
12
12
|
const rows: StoreRow[] = [];
|
|
13
|
-
// apps can RSVP wherever the calendar is on; room bookings need bookings too
|
|
14
|
-
const contact = [...(f.bookings ? ['bookings'] : []), ...(f.calendar ? ['RSVPs'] : []), ...(f.appForms ? ['forms'] : [])];
|
|
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'] : [])];
|
|
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({
|
package/src/lib/legal/text-en.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
|
42
42
|
] },
|
|
43
43
|
];
|
|
44
44
|
if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Bookings and RSVPs' : 'Event RSVPs', text: [
|
|
45
|
-
`When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}. When you RSVP or cancel an RSVP, the event’s hosts (or our staff) get an email with your RSVP details.`,
|
|
45
|
+
`When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}. When you RSVP or cancel an RSVP, the event’s hosts (or our staff) get an email with your RSVP details. If a date is full and you join its waitlist, we keep your request like an RSVP and email you if a spot opens; we hold that spot for 24 hours.`,
|
|
46
46
|
'Each booking has a private link to see, change or cancel it. Keep it private: anyone with the link can use it.',
|
|
47
47
|
] });
|
|
48
48
|
s.push({ id: 'forms', title: 'Forms', text: [
|
package/src/lib/legal/text-es.ts
CHANGED
|
@@ -43,7 +43,7 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
|
43
43
|
] },
|
|
44
44
|
];
|
|
45
45
|
if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Reservaciones y registros' : 'Registros a eventos', text: [
|
|
46
|
-
`Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}. Cuando se registra a un evento o cancela su registro, los organizadores del evento (o nuestro personal) reciben un correo con los datos de su registro.`,
|
|
46
|
+
`Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}. Cuando se registra a un evento o cancela su registro, los organizadores del evento (o nuestro personal) reciben un correo con los datos de su registro. Si una fecha está llena y usted se une a su lista de espera, guardamos su solicitud como un registro y le escribimos si se libera un lugar; se lo apartamos 24 horas.`,
|
|
47
47
|
'Cada reservación tiene un enlace privado para verla, cambiarla o cancelarla. Manténgalo en privado: cualquiera que lo tenga puede usarlo.',
|
|
48
48
|
] });
|
|
49
49
|
s.push({ id: 'forms', title: 'Formularios', text: [
|
|
@@ -23,8 +23,9 @@ export const bookingInput = z.union([
|
|
|
23
23
|
z.object({ ...common, resource: z.string().min(1).max(200), link: z.string().min(1).max(200),
|
|
24
24
|
minutes: z.number().int().min(1).max(44640).optional(),
|
|
25
25
|
}).strict(),
|
|
26
|
-
// formVersion: from GET /events/{slug}/rsvp; required when the event asks questions
|
|
27
|
-
|
|
26
|
+
// formVersion: from GET /events/{slug}/rsvp; required when the event asks questions.
|
|
27
|
+
// waitlist: a full date takes a place in line instead (when the event keeps a waitlist)
|
|
28
|
+
z.object({ ...common, event: z.string().min(1).max(200), seats: z.number().int().min(1).max(1000).default(1), formVersion: z.string().max(100).optional(), waitlist: z.boolean().optional() }).strict(),
|
|
28
29
|
]);
|
|
29
30
|
function canonical(value: unknown): string {
|
|
30
31
|
if (value && typeof value === 'object' && !Array.isArray(value)) return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k,v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
|
|
@@ -67,7 +68,7 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
67
68
|
// the app must have seen the current questions: a changed form then answers 409, as on the website
|
|
68
69
|
// (closed dates fall through to createRsvp's own answer)
|
|
69
70
|
if (!body.formVersion && rsvpQuestions(event) && !rsvpClosed(occurrence, settings)) throw new ApiError(400, 'This event asks questions. Send formVersion from GET /api/v1/events/{slug}/rsvp with the answers.');
|
|
70
|
-
booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker, source: 'app' }, flow);
|
|
71
|
+
booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker, source: 'app', waitlist: body.waitlist }, flow);
|
|
71
72
|
} else {
|
|
72
73
|
const resource = await repo.getResourceBySlug(body.resource);
|
|
73
74
|
const link = resource ? await repo.getBookingLinkBySlug(resource.id, body.link) : null;
|
package/src/lib/mobile/manage.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { eventTimeZone, locationLine } from '../calendar/events';
|
|
|
6
6
|
import { resourceZone, linkLengths } from '../booking/availability';
|
|
7
7
|
import { bookingIcs, bookingLanguage, bookingManagePath } from '../booking/emails';
|
|
8
8
|
import { localePath } from '../i18n';
|
|
9
|
-
import { guestCancel, guestChanges, guestMoveLink, guestReschedule } from '../booking/service';
|
|
9
|
+
import { confirmOffer, guestCancel, guestChanges, guestMoveLink, guestReschedule, waitlistPosition } from '../booking/service';
|
|
10
10
|
import { siteOrigin } from '../seo';
|
|
11
11
|
import { waitUntilFrom } from '../webhooks';
|
|
12
12
|
import { isoDay, queryParams } from './http';
|
|
@@ -44,7 +44,8 @@ async function managed(c: PublicContext): Promise<Managed> {
|
|
|
44
44
|
return { booking, settings, resource, link, event, origin: siteOrigin(settings, c.url) };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// position: a waitlisted RSVP's place in line (view() can't query)
|
|
48
|
+
function view(m: Managed, booking = m.booking, position: number | null = null) {
|
|
48
49
|
const { resource, link, event, settings, origin } = m;
|
|
49
50
|
const can = guestChanges(booking, link);
|
|
50
51
|
return {
|
|
@@ -55,6 +56,8 @@ function view(m: Managed, booking = m.booking) {
|
|
|
55
56
|
timezone: event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC',
|
|
56
57
|
seats: booking.seats, name: booking.name, email: booking.email, phone: booking.phone, answers: booking.answers,
|
|
57
58
|
createdAt: new Date(booking.createdAt).toISOString(),
|
|
59
|
+
// in line: its place (1 = next); a held spot: until when
|
|
60
|
+
position, holdUntil: booking.status === 'offered' && booking.holdUntil !== null ? new Date(booking.holdUntil).toISOString() : null,
|
|
58
61
|
},
|
|
59
62
|
title: event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
|
|
60
63
|
location: event ? locationLine(event.location) : resource?.name ?? '',
|
|
@@ -71,13 +74,24 @@ function view(m: Managed, booking = m.booking) {
|
|
|
71
74
|
}
|
|
72
75
|
const flow = (m: Managed, c: PublicContext) => ({ origin: m.origin, locals: c.locals, waitUntil: waitUntilFrom(c.locals) });
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
const placed = async (c: PublicContext, m: Managed, booking = m.booking) => view(m, booking, await waitlistPosition(c.repo, booking));
|
|
78
|
+
|
|
79
|
+
export async function getManaged(c: PublicContext) {
|
|
80
|
+
const m = await managed(c);
|
|
81
|
+
return placed(c, m);
|
|
82
|
+
}
|
|
75
83
|
|
|
76
84
|
export async function cancelManaged(c: PublicContext) {
|
|
77
85
|
const m = await managed(c);
|
|
78
86
|
return view(m, await guestCancel(c.repo, m.booking, flow(m, c), 'app'));
|
|
79
87
|
}
|
|
80
88
|
|
|
89
|
+
// take the spot the waitlist offered them; past its hold, 410 and it goes to the next person
|
|
90
|
+
export async function confirmManaged(c: PublicContext) {
|
|
91
|
+
const m = await managed(c);
|
|
92
|
+
return view(m, await confirmOffer(c.repo, m.booking, flow(m, c), 'app'));
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
export const manageAvailabilityQuery = z.object({
|
|
82
96
|
from: isoDay.optional(), days: z.coerce.number().int().min(1).max(31).default(7), minutes: z.coerce.number().int().min(1).max(44640).optional(),
|
|
83
97
|
}).strict();
|
|
@@ -80,7 +80,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
80
80
|
paths['/api/v1/bookings'] = {
|
|
81
81
|
post: {
|
|
82
82
|
summary: 'Create a guest booking or an RSVP, or replay an existing request; RSVPs require calendar, room bookings also bookings',
|
|
83
|
-
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409. An RSVP from an email that already has one for that date answers 409 with code duplicate (and emails that RSVP’s link again); past the event’s RSVP deadline, 410.',
|
|
83
|
+
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409. An RSVP from an email that already has one for that date (a place on its waitlist too) answers 409 with code duplicate (and emails that RSVP’s link again); past the event’s RSVP deadline, 410. A full date answers 409, unless the event keeps a waitlist (RsvpStatus.waitlist.enabled) and the RSVP sends waitlist: true: then it takes a place in line (status waitlisted, no seats) and is emailed when a spot opens.',
|
|
84
84
|
parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
|
|
85
85
|
requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
|
|
86
86
|
responses: { ...errors,
|
|
@@ -104,7 +104,9 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
104
104
|
const cors = { options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } } };
|
|
105
105
|
const manageNote = 'Requires the calendar and the owner switch bookingChanges. Same rules and emails as the booking’s web page; a booking type’s change cutoff closes guest changes that many hours before the start.';
|
|
106
106
|
paths['/api/v1/bookings/manage'] = { get: priv('The booker’s own booking: status, time, place, what they may still change', ref('ManagedBooking'), { header: bookingToken, description: manageNote }), ...cors };
|
|
107
|
-
paths['/api/v1/bookings/manage/cancel'] = { post: priv('Cancel the booking (repeating is harmless)', ref('ManagedBooking'), { header: bookingToken, write: true, description: manageNote }), ...cors };
|
|
107
|
+
paths['/api/v1/bookings/manage/cancel'] = { post: priv('Cancel the booking (repeating is harmless); for an RSVP on the waitlist, leave it or release a held spot', ref('ManagedBooking'), { header: bookingToken, write: true, description: manageNote }), ...cors };
|
|
108
|
+
paths['/api/v1/bookings/manage/confirm'] = { post: priv('Take a spot the waitlist offered (status offered, can.confirm): confirmed, or pending when the event approves RSVPs; repeating is harmless', ref('ManagedBooking'), { header: bookingToken, write: true,
|
|
109
|
+
description: `${manageNote} Past its holdUntil the spot was released (410) and goes to the next person in line; a booking with no held spot answers 409.` }), ...cors };
|
|
108
110
|
paths['/api/v1/bookings/manage/availability'] = { get: priv('Open times for moving the booking (its own time counts as free)', ref('Availability'), { header: bookingToken, query: manageAvailabilityQuery, description: manageNote }), ...cors };
|
|
109
111
|
paths['/api/v1/bookings/manage/reschedule'] = { post: priv('Move the booking to a new start (room bookings, not RSVPs)', ref('ManagedBooking'), { header: bookingToken, body: rescheduleInput, write: true, description: manageNote }), ...cors };
|
|
110
112
|
paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
|
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { CalEvent, Settings } from '../schema';
|
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { answerFields } from '../forms/schema';
|
|
6
6
|
import { eventIsPublic, expandEvents, rsvpDeadline, type Occurrence } from '../calendar/events';
|
|
7
|
-
import { seatsTaken } from '../booking/service';
|
|
7
|
+
import { seatsTaken, waitlistLine, waitlistOn } from '../booking/service';
|
|
8
8
|
import { localizeDoc } from '../translations';
|
|
9
9
|
import { siteLang } from '../dates';
|
|
10
10
|
import { rsvpStrings } from '../calendar/rsvp-words';
|
|
@@ -14,9 +14,11 @@ import { formJson } from './forms';
|
|
|
14
14
|
import { localeQuery, mobileLocale } from './locale';
|
|
15
15
|
|
|
16
16
|
// RSVP status of one date of an event, for apps: whether it takes RSVPs now,
|
|
17
|
-
// live seats and the questions with their version. The same
|
|
18
|
-
// event's web page: open until the event's deadline or the date
|
|
19
|
-
//
|
|
17
|
+
// live seats, its waitlist and the questions with their version. The same
|
|
18
|
+
// rules as the event's web page: open until the event's deadline or the date
|
|
19
|
+
// ends; a full date with a waitlist takes places in line (POST /bookings with
|
|
20
|
+
// waitlist: true). Never cached; seats change with every RSVP. POST /bookings
|
|
21
|
+
// saves one.
|
|
20
22
|
|
|
21
23
|
/** a public event (slug or id) and its occurrence starting exactly at `start` */
|
|
22
24
|
export async function rsvpOccurrence(repo: ContentRepository, ref: string, start: string): Promise<Occurrence> {
|
|
@@ -52,8 +54,9 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
52
54
|
const o = await rsvpOccurrence(repo, slug, q.start);
|
|
53
55
|
const settings = await repo.getSettings();
|
|
54
56
|
const { rsvp } = o.event;
|
|
55
|
-
// pending and
|
|
57
|
+
// pending, confirmed and held (waitlist) RSVPs take seats, as in the capacity check
|
|
56
58
|
const going = rsvp.enabled ? await seatsTaken(repo, o.event.id, o.start) : 0;
|
|
59
|
+
const line = waitlistOn(o.event) ? await waitlistLine(repo, o.event.id, o.start) : [];
|
|
57
60
|
const left = rsvp.capacity ? Math.max(0, rsvp.capacity - going) : null;
|
|
58
61
|
const closed = rsvpClosed(o, settings);
|
|
59
62
|
const deadline = rsvp.enabled ? rsvpDeadline(o, settings) : null;
|
|
@@ -74,6 +77,8 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
74
77
|
closesAt: deadline === null ? null : new Date(deadline).toISOString(),
|
|
75
78
|
// seats one RSVP may take right now
|
|
76
79
|
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
80
|
+
// a full date takes places in line: how many people wait, for how many seats, and the most one may ask for
|
|
81
|
+
waitlist: { enabled: waitlistOn(o.event), waiting: line.length, seats: line.reduce((n, b) => n + b.seats, 0), maxSeats: rsvp.maxSeats || 1 },
|
|
77
82
|
form: form ? { ...await formJson(form), submitLabel } : null,
|
|
78
83
|
};
|
|
79
84
|
}
|
|
@@ -77,13 +77,20 @@ export const RsvpStatus = z.object({
|
|
|
77
77
|
going: z.number().int(), left: z.number().int().nullable(), capacity: z.number().int(), maxSeats: z.number().int(), autoConfirm: bool,
|
|
78
78
|
// the event's RSVP deadline for this date (null: open until it ends)
|
|
79
79
|
closesAt: date.nullable(),
|
|
80
|
+
// enabled: a full date (reason full) takes places in line; waiting: people in line, seats: theirs; maxSeats: the most one may ask for
|
|
81
|
+
waitlist: z.object({ enabled: bool, waiting: z.number().int(), seats: z.number().int(), maxSeats: z.number().int() }),
|
|
80
82
|
form: Form.omit({ id: true }).nullable(),
|
|
81
83
|
});
|
|
82
|
-
|
|
84
|
+
// confirm: a spot offered from the waitlist is still held for them
|
|
85
|
+
const GuestChanges = z.object({ cancel: bool, reschedule: bool, confirm: bool, cutoffHours: z.number().int(), closed: bool });
|
|
86
|
+
// waitlisted: in line for a full date; offered: a freed spot held until holdUntil; expired: not confirmed in time
|
|
87
|
+
const BookingStatus = z.enum(['pending', 'confirmed', 'declined', 'cancelled', 'waitlisted', 'offered', 'expired']);
|
|
83
88
|
export const ManagedBooking = z.object({
|
|
84
89
|
booking: z.object({
|
|
85
|
-
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status:
|
|
90
|
+
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status: BookingStatus,
|
|
86
91
|
start: date, end: date, allDay: bool, timezone: str, seats: z.number().int(), name: str, email: str, phone: str, answers: z.record(str), createdAt: date,
|
|
92
|
+
// waitlisted: the place in line (1 = next); offered: until when the spot is held
|
|
93
|
+
position: z.number().int().nullable(), holdUntil: date.nullable(),
|
|
87
94
|
}),
|
|
88
95
|
title: str, location: str,
|
|
89
96
|
resource: z.object({ slug: str, name: str }).nullable(),
|
|
@@ -96,7 +103,7 @@ export const ManagedBooking = z.object({
|
|
|
96
103
|
export const GuestCodeSent = z.object({ sent: z.literal(true), expiresInMinutes: z.number().int() });
|
|
97
104
|
export const GuestToken = z.object({ accessToken: str, tokenType: z.literal('Bearer'), email: str, idleDays: z.number().int() });
|
|
98
105
|
export const GuestBookings = z.object({ email: str, pagination: Pagination, items: z.array(z.object({
|
|
99
|
-
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status:
|
|
106
|
+
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status: BookingStatus,
|
|
100
107
|
start: date, end: date, seats: z.number().int(), title: str, manageToken: str, manageUrl: str, can: GuestChanges,
|
|
101
108
|
})) });
|
|
102
109
|
export const GuestSignedOut = z.object({ signedOut: z.literal(true) });
|
|
@@ -690,6 +690,11 @@ export const MIGRATIONS: Migration[] = [
|
|
|
690
690
|
{ version: 60, statements: [
|
|
691
691
|
`ALTER TABLE bookings ADD COLUMN locale TEXT NOT NULL DEFAULT ''`,
|
|
692
692
|
] },
|
|
693
|
+
// RSVP waitlists: an offered spot is held until hold_until (ms); null for
|
|
694
|
+
// everything else, so older rows need no backfill
|
|
695
|
+
{ version: 61, statements: [
|
|
696
|
+
`ALTER TABLE bookings ADD COLUMN hold_until INTEGER`,
|
|
697
|
+
] },
|
|
693
698
|
];
|
|
694
699
|
|
|
695
700
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -212,9 +212,14 @@ function rowToBooking(r: Row): Booking {
|
|
|
212
212
|
formResponse: r.form_response ? json(r.form_response, undefined) : undefined,
|
|
213
213
|
answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web', locale: r.locale ?? '',
|
|
214
214
|
notificationVersion: Number(r.notification_version ?? 0), reminderSentAt: r.reminder_sent_at === null || r.reminder_sent_at === undefined ? null : Number(r.reminder_sent_at),
|
|
215
|
+
holdUntil: r.hold_until === null || r.hold_until === undefined ? null : Number(r.hold_until),
|
|
215
216
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
216
217
|
});
|
|
217
218
|
}
|
|
219
|
+
// RSVP statuses that take seats (a held offer too), and those that are the
|
|
220
|
+
// address's RSVP for a date (a place in line too)
|
|
221
|
+
const SEATED = "('pending','confirmed','offered')";
|
|
222
|
+
const RSVP_ACTIVE = "('pending','confirmed','offered','waitlisted')";
|
|
218
223
|
function rowToApiKey(r: Row): ApiKey {
|
|
219
224
|
return ApiKey.parse({
|
|
220
225
|
id: r.id, name: r.name, prefix: r.prefix, scopes: json(r.scopes, []),
|
|
@@ -446,7 +451,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
446
451
|
await this.ready;
|
|
447
452
|
// One SELECT snapshots bookings and their retry ledger together. No UI
|
|
448
453
|
// list limit; include tombstones for deleted bookings as well.
|
|
449
|
-
const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','locale','reminder_sent_at','notification_version','created_at','updated_at'];
|
|
454
|
+
const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','locale','reminder_sent_at','hold_until','notification_version','created_at','updated_at'];
|
|
450
455
|
const rows = await this.driver.all(`SELECT 'booking' AS type, json_object(${columns.map(c => `'${c}',${c}`).join(',')}) AS data FROM bookings
|
|
451
456
|
UNION ALL SELECT 'request', json_object('keyHash',key_hash,'requestHash',request_hash,'bookingId',booking_id,'createdAt',created_at) FROM mobile_booking_requests`);
|
|
452
457
|
return {
|
|
@@ -917,6 +922,13 @@ export class SqlRepository implements ContentRepository {
|
|
|
917
922
|
start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
|
|
918
923
|
end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
|
|
919
924
|
WHERE event_id=? AND kind='rsvp' AND status IN ('pending','confirmed')`,args:[id,siteTimezone,cancelled?1:0,single?1:0,id,single?1:0,id,id]},
|
|
925
|
+
// places in line and held spots move with the date too; only its cancellation emails them
|
|
926
|
+
{sql:`UPDATE bookings SET notification_version=notification_version+?,
|
|
927
|
+
timezone=COALESCE(NULLIF((SELECT timezone FROM events WHERE id=?),''),?),
|
|
928
|
+
status=CASE WHEN ? THEN 'cancelled' ELSE status END,
|
|
929
|
+
start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
|
|
930
|
+
end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
|
|
931
|
+
WHERE event_id=? AND kind='rsvp' AND status IN ('waitlisted','offered')`,args:[cancelled?1:0,id,siteTimezone,cancelled?1:0,single?1:0,id,single?1:0,id,id]},
|
|
920
932
|
...notifications]);
|
|
921
933
|
} else await this.driver.batch([eventWrite]);
|
|
922
934
|
await this.syncEventIndex(id);
|
|
@@ -948,7 +960,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
948
960
|
const mutation = crypto.randomUUID();
|
|
949
961
|
const notifications = bookingJobs(id,'cancelled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp'")}));
|
|
950
962
|
await this.driver.batch([
|
|
951
|
-
{sql
|
|
963
|
+
{sql:`UPDATE bookings SET status='cancelled',notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=? WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ${RSVP_ACTIVE}`,args:[mutation,Date.now(),id,start]},
|
|
952
964
|
...notifications,
|
|
953
965
|
]);
|
|
954
966
|
return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
|
|
@@ -972,7 +984,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
972
984
|
const mutation = crypto.randomUUID();
|
|
973
985
|
const notifications = bookingJobs(id,'cancelled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp'")}));
|
|
974
986
|
await this.driver.batch([
|
|
975
|
-
{sql
|
|
987
|
+
{sql:`UPDATE bookings SET status='cancelled',notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL WHERE event_id=? AND kind='rsvp' AND status IN ${RSVP_ACTIVE}`,args:[mutation,id]},
|
|
976
988
|
...notifications,
|
|
977
989
|
{ sql: 'DELETE FROM events WHERE id = ?', args: [id] },
|
|
978
990
|
{ sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [id] },
|
|
@@ -1078,9 +1090,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1078
1090
|
private bookingInsert(b: Booking): SqlStatement {
|
|
1079
1091
|
return {
|
|
1080
1092
|
sql: `INSERT INTO bookings (id, kind, resource_id, link_id, event_id, status, start_at, end_at, hold_start, hold_end, timezone, name, email, phone,
|
|
1081
|
-
seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response, locale) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1093
|
+
seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response, locale, hold_until) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1082
1094
|
args: [b.id, b.kind, b.resourceId, b.linkId, b.eventId, b.status, b.start, b.end, b.holdStart, b.holdEnd, b.timezone, b.name, b.email, b.phone,
|
|
1083
|
-
b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null, b.locale],
|
|
1095
|
+
b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null, b.locale, b.holdUntil],
|
|
1084
1096
|
};
|
|
1085
1097
|
}
|
|
1086
1098
|
async listResources() {
|
|
@@ -1221,12 +1233,13 @@ export class SqlRepository implements ContentRepository {
|
|
|
1221
1233
|
const holds = b.status === 'pending' || b.status === 'confirmed';
|
|
1222
1234
|
let write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE 1=1`, args: ins.args };
|
|
1223
1235
|
if (b.kind === 'rsvp') {
|
|
1224
|
-
|
|
1236
|
+
// a place in line takes no seats
|
|
1237
|
+
const cap = holds ? guard.capacity ?? 0 : 0;
|
|
1225
1238
|
write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')}
|
|
1226
|
-
WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN
|
|
1239
|
+
WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN ${SEATED}) + ? <= ?)`,
|
|
1227
1240
|
args: [...ins.args!,cap,b.eventId,b.start,b.seats,cap] };
|
|
1228
1241
|
// same statement, so two at once from one address can't both get in
|
|
1229
|
-
if (guard.oncePerEmail && holds && b.email) write = { sql: write.sql + ` AND NOT EXISTS (SELECT 1 FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND email=? AND status IN
|
|
1242
|
+
if (guard.oncePerEmail && (holds || b.status === 'waitlisted') && b.email) write = { sql: write.sql + ` AND NOT EXISTS (SELECT 1 FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND email=? AND status IN ${RSVP_ACTIVE})`,
|
|
1230
1243
|
args: [...write.args!,b.eventId,b.start,b.email] };
|
|
1231
1244
|
} else if (holds && b.resourceId) {
|
|
1232
1245
|
write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE NOT EXISTS (
|
|
@@ -1241,7 +1254,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1241
1254
|
SELECT ?,?,id,? FROM bookings WHERE id=?`, args: [keyHash, requestHash, now, b.id] });
|
|
1242
1255
|
}
|
|
1243
1256
|
// Booking, retry ledger and email outbox commit together on D1 and libSQL.
|
|
1244
|
-
await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : 'received', 0, true), ...rsvpStaffJobs(b.id, 'staff-rsvp', guard.staff, 0)]);
|
|
1257
|
+
await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : b.status === 'waitlisted' ? 'waitlisted' : 'received', 0, true), ...rsvpStaffJobs(b.id, 'staff-rsvp', guard.staff, 0)]);
|
|
1245
1258
|
return this.getBooking(b.id);
|
|
1246
1259
|
}
|
|
1247
1260
|
async moveBooking(id: string, t: { start: number; end: number; holdStart: number; holdEnd: number }) {
|
|
@@ -1276,10 +1289,37 @@ export class SqlRepository implements ContentRepository {
|
|
|
1276
1289
|
if (data.answers !== undefined) { sets.push('answers = ?'); args.push(JSON.stringify(data.answers)); }
|
|
1277
1290
|
if (data.reminderSentAt !== undefined) { sets.push('reminder_sent_at = ?'); args.push(data.reminderSentAt); }
|
|
1278
1291
|
args.push(id);
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1292
|
+
if (data.from && data.from !== current.status) return;
|
|
1293
|
+
// the guest's email: leaving the line (or a held spot) isn't a cancelled RSVP; a spot that ran out sends none
|
|
1294
|
+
const waiting = current.status === 'waitlisted' || current.status === 'offered';
|
|
1295
|
+
const kind = data.status === 'confirmed' ? 'confirmed' : data.status === 'declined' ? 'declined' : data.status === 'pending' ? 'received'
|
|
1296
|
+
: data.status === 'offered' ? 'offered' : data.status === 'expired' ? '' : waiting ? 'left' : 'cancelled';
|
|
1297
|
+
// staff: a confirmed offer is a new RSVP to them
|
|
1298
|
+
const staff = data.status === 'cancelled' ? 'staff-rsvp-cancelled' as const : data.status === 'confirmed' || data.status === 'pending' ? 'staff-rsvp' as const : null;
|
|
1299
|
+
await this.driver.batch([{sql:`UPDATE bookings SET ${sets.join(', ')} WHERE id = ? AND notification_version = ?${data.from ? ' AND status = ?' : ''}`,args:[...args,current.notificationVersion,...(data.from ? [data.from] : [])]},
|
|
1300
|
+
...(changed && kind ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : []),
|
|
1301
|
+
...(changed && staff ? rsvpStaffJobs(id,staff,data.staff,current.notificationVersion+1,mutation) : [])]);
|
|
1302
|
+
}
|
|
1303
|
+
// waitlisted → offered only while its seats still fit: the seat count is in
|
|
1304
|
+
// the same statement, like createBooking's, so two offers (or an offer and a
|
|
1305
|
+
// new RSVP) can't both take the last seats. The email is queued with it.
|
|
1306
|
+
async offerBooking(id: string, holdUntil: number, capacity: number) {
|
|
1307
|
+
await this.ready;
|
|
1308
|
+
const mutation = crypto.randomUUID();
|
|
1309
|
+
await this.driver.batch([
|
|
1310
|
+
{ sql: `UPDATE bookings SET status='offered',hold_until=?,notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=?
|
|
1311
|
+
WHERE id=? AND kind='rsvp' AND status='waitlisted' AND (? = 0 OR (SELECT COALESCE(SUM(t.seats),0) FROM bookings t WHERE t.kind='rsvp' AND t.event_id=bookings.event_id AND t.start_at=bookings.start_at AND t.status IN ${SEATED}) + seats <= ?)`,
|
|
1312
|
+
args: [holdUntil, mutation, Date.now(), id, capacity, capacity] },
|
|
1313
|
+
...bookingJobs(id, 'offered', undefined, false, mutation),
|
|
1314
|
+
]);
|
|
1315
|
+
return !!await this.first('SELECT id FROM bookings WHERE id=? AND email_mutation=?', [id, mutation]);
|
|
1316
|
+
}
|
|
1317
|
+
async expireBooking(id: string, now: number) {
|
|
1318
|
+
await this.ready;
|
|
1319
|
+
const mutation = crypto.randomUUID();
|
|
1320
|
+
await this.driver.run(`UPDATE bookings SET status='expired',notification_version=notification_version+1,email_mutation=?,updated_at=?
|
|
1321
|
+
WHERE id=? AND kind='rsvp' AND status='offered' AND hold_until <= ?`, [mutation, Date.now(), id, now]);
|
|
1322
|
+
return !!await this.first('SELECT id FROM bookings WHERE id=? AND email_mutation=?', [id, mutation]);
|
|
1283
1323
|
}
|
|
1284
1324
|
async deleteBooking(id: string) {
|
|
1285
1325
|
await this.ready;
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -198,15 +198,22 @@ export interface ContentRepository {
|
|
|
198
198
|
getBookingByToken(token: string): Promise<Booking | null>;
|
|
199
199
|
// bookings + RSVPs (never staff blocks) this address made since `since`
|
|
200
200
|
countBookingsByEmail(email: string, since: number): Promise<number>;
|
|
201
|
-
// locale: the booker's language; '' (the main language) when left out
|
|
201
|
+
// locale: the booker's language; '' (the main language) when left out.
|
|
202
|
+
// A waitlisted RSVP takes no seats, so only oncePerEmail guards it.
|
|
202
203
|
createBooking(
|
|
203
|
-
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale'> & { locale?: string },
|
|
204
|
+
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale' | 'holdUntil'> & { locale?: string },
|
|
204
205
|
guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string }; staff?: RsvpStaffNotice }
|
|
205
206
|
): Promise<Booking | null>;
|
|
206
207
|
// moves a booking only if the new held range is free (reschedule)
|
|
207
208
|
moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
|
|
208
|
-
// staff: who hears that the person cancelled their RSVP
|
|
209
|
-
|
|
209
|
+
// staff: who hears that the person cancelled their RSVP, or confirmed a spot
|
|
210
|
+
// offered from the waitlist; from: change it only if it still has that status
|
|
211
|
+
updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { from?: BookingStatus; notificationMessage?: string; staff?: RsvpStaffNotice }): Promise<void>;
|
|
212
|
+
// a waitlisted RSVP becomes offered (held until holdUntil, its email queued
|
|
213
|
+
// with it) only if its seats still fit `capacity` (0 = no limit); false when they don't
|
|
214
|
+
offerBooking(id: string, holdUntil: number, capacity: number): Promise<boolean>;
|
|
215
|
+
// an offered RSVP whose hold ended by `now` becomes expired (no email); false when it wasn't
|
|
216
|
+
expireBooking(id: string, now: number): Promise<boolean>;
|
|
210
217
|
deleteBooking(id: string): Promise<void>;
|
|
211
218
|
// confirmed bookings starting in [from, to) that haven't had a reminder
|
|
212
219
|
listDueReminders(from: number, to: number): Promise<Booking[]>;
|