@acms-subir/core 0.3.40 → 0.3.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/cli/migrations.generated.mjs +15 -0
  3. package/docs/mobile.md +12 -2
  4. package/integration.mjs +4 -0
  5. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
  6. package/mobile-template/recipes/push/files/app/link-phone.tsx +43 -0
  7. package/mobile-template/recipes/push/files/app/notifications.tsx +14 -4
  8. package/mobile-template/recipes/push/recipe.json +4 -2
  9. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
  10. package/mobile-template/recipes/rsvp/recipe.json +2 -2
  11. package/package.json +1 -1
  12. package/src/actions/index.ts +1 -1
  13. package/src/components/Announcements.tsx +11 -2
  14. package/src/components/EventPage.astro +16 -8
  15. package/src/components/MobileApp.tsx +20 -8
  16. package/src/components/TestPhones.tsx +177 -0
  17. package/src/components/editor/EventPanel.tsx +2 -2
  18. package/src/components/editor/PostEditor.tsx +1 -1
  19. package/src/components/editor/event/RegistrationCard.tsx +5 -0
  20. package/src/components/editor/event/registration.ts +1 -1
  21. package/src/lib/announcements/api.ts +19 -2
  22. package/src/lib/announcements/service.ts +1 -1
  23. package/src/lib/booking/agent.ts +1 -1
  24. package/src/lib/booking/emails.ts +20 -5
  25. package/src/lib/booking/service.ts +148 -14
  26. package/src/lib/calendar/rsvp-words.ts +69 -8
  27. package/src/lib/content-service.ts +11 -6
  28. package/src/lib/drafts.ts +2 -2
  29. package/src/lib/editor/bookings.ts +5 -3
  30. package/src/lib/editor/documents.ts +3 -2
  31. package/src/lib/editor/schemas.ts +1 -1
  32. package/src/lib/email/booking-words.ts +26 -2
  33. package/src/lib/email/service.ts +14 -2
  34. package/src/lib/email/store.ts +6 -2
  35. package/src/lib/email/templates.ts +20 -0
  36. package/src/lib/forms/events.ts +4 -2
  37. package/src/lib/legal/app-privacy.ts +4 -4
  38. package/src/lib/legal/text-en.ts +2 -2
  39. package/src/lib/legal/text-es.ts +2 -2
  40. package/src/lib/mobile/bookings.ts +4 -3
  41. package/src/lib/mobile/guest.ts +2 -1
  42. package/src/lib/mobile/manage.ts +17 -3
  43. package/src/lib/mobile/openapi.ts +22 -5
  44. package/src/lib/mobile/rsvp.ts +10 -5
  45. package/src/lib/mobile/schemas.ts +14 -3
  46. package/src/lib/push/api.ts +37 -8
  47. package/src/lib/push/service.ts +6 -2
  48. package/src/lib/push/staff.ts +162 -0
  49. package/src/lib/push/store.ts +49 -2
  50. package/src/lib/push/types.ts +4 -1
  51. package/src/lib/repo/migrations.ts +15 -0
  52. package/src/lib/repo/sql.ts +57 -14
  53. package/src/lib/repo/types.ts +11 -4
  54. package/src/lib/scheduler.ts +3 -1
  55. package/src/lib/schema.ts +10 -2
  56. package/src/pages/admin/calendar/bookings.astro +57 -13
  57. package/src/pages/api/announcements.ts +2 -2
  58. package/src/pages/api/calendar/bookings/index.ts +1 -1
  59. package/src/pages/api/calendar/docs.ts +2 -2
  60. package/src/pages/api/push/admin.ts +1 -1
  61. package/src/pages/api/push/phones.ts +25 -0
  62. package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
  63. package/src/pages/api/v1/devices/[token]/staff-link.ts +20 -0
  64. package/src/pages/book/manage/[token].astro +57 -15
  65. package/src/pages/events/[slug].astro +3 -2
  66. package/src/styles/admin.css +3 -1
  67. package/src/styles/site.css +10 -1
  68. package/src/styles/test-phones.css +30 -0
  69. package/src/theme-sdk/index.ts +1 -1
@@ -8,6 +8,7 @@ import { CONTENT, collectionUrl } from './content';
8
8
  import { CALENDAR } from './calendar/config';
9
9
  import { formatWall, isValidTimeZone, normalizeEventTimes, parseWall } from './calendar/events';
10
10
  import { eventChanged } from './calendar/hooks';
11
+ import { offerEventWaitlist } from './booking/service';
11
12
  import { normalizePageLocale } from './i18n';
12
13
  import { siteOrigin } from './seo';
13
14
  import { waitUntilFrom } from './webhooks';
@@ -37,10 +38,10 @@ export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blo
37
38
  // location (see mergeLocationInput) — older clients send name/address/url only
38
39
  // cancelledDates is left out: single dates are cancelled at once through
39
40
  // cancelOccurrence/restoreOccurrence (lib/calendar/schedule.ts), never staged
40
- // rsvp may leave out the deadline and notifyStaff (older clients): the current ones stay
41
+ // rsvp may leave out the deadline, notifyStaff and waitlist (older clients): the current ones stay
41
42
  export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location' | 'cancelledDates' | 'rsvp'>> & {
42
43
  location?: Partial<EventLocation>;
43
- rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff'>>;
44
+ rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff' | 'waitlist'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff' | 'waitlist'>>;
44
45
  };
45
46
  type EventClean = Omit<EventSave, 'location' | 'rsvp'> & { location?: EventLocation; rsvp?: CalEvent['rsvp'] };
46
47
 
@@ -136,11 +137,11 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
136
137
  const out: EventClean = { ...rest };
137
138
  if (rsvp) {
138
139
  // Older editor clients resend the original RSVP shape. Omission must not
139
- // erase questions, a deadline or the staff emails setting that were
140
- // authored in a newer editor.
140
+ // erase questions, a deadline, the staff emails setting or the waitlist
141
+ // that were authored in a newer editor.
141
142
  const previous = mergeEventDraft(before).rsvp;
142
143
  const next: CalEvent['rsvp'] = { ...rsvp, closesAt: rsvp.closesAt === undefined ? previous.closesAt ?? null : rsvp.closesAt || null, closeBefore: rsvp.closeBefore ?? previous.closeBefore ?? 0,
143
- notifyStaff: rsvp.notifyStaff ?? previous.notifyStaff ?? true };
144
+ notifyStaff: rsvp.notifyStaff ?? previous.notifyStaff ?? true, waitlist: rsvp.waitlist ?? previous.waitlist ?? false };
144
145
  if (!Object.hasOwn(rsvp, 'form') && previous.form) next.form = previous.form;
145
146
  if (next.form !== undefined) {
146
147
  const form = FormDefinition.safeParse(next.form);
@@ -156,6 +157,7 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
156
157
  throw new ApiError(400, 'The RSVP deadline can be at most 60 days before each date (closeBefore: 0–86400 minutes).');
157
158
  }
158
159
  if (typeof next.notifyStaff !== 'boolean') throw new ApiError(400, 'rsvp.notifyStaff must be true or false.');
160
+ if (typeof next.waitlist !== 'boolean') throw new ApiError(400, 'rsvp.waitlist must be true or false.');
159
161
  out.rsvp = next;
160
162
  }
161
163
  if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
@@ -219,9 +221,12 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
219
221
 
220
222
  export async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, before: CalEvent, after: CalEvent, applied = false) {
221
223
  const settings = await repo.getSettings();
224
+ const origin = siteOrigin(settings, hooks.url);
222
225
  await eventChanged(repo, before, after, {
223
- origin: siteOrigin(settings, hooks.url), waitUntil: waitUntilFrom(hooks.locals), applied, onRedirect: hooks.onRedirect,
226
+ origin, waitUntil: waitUntilFrom(hooks.locals), applied, onRedirect: hooks.onRedirect,
224
227
  });
228
+ // seats an edit freed (a higher capacity) go to those in line now, not at the next sweep
229
+ if (after.rsvp.waitlist) await offerEventWaitlist(repo, after.id, { origin, locals: hooks.locals, waitUntil: waitUntilFrom(hooks.locals) });
225
230
  }
226
231
 
227
232
  export async function saveEvent(repo: ContentRepository, id: string, data: EventSave, hooks: SaveHooks): Promise<CalEvent> {
package/src/lib/drafts.ts CHANGED
@@ -52,8 +52,8 @@ export function pickEventDraft(draft: Record<string, unknown>): Partial<Pick<Cal
52
52
  const loc = EventLocation.safeParse(out.location);
53
53
  if (loc.success) out.location = loc.data; else delete out.location;
54
54
  }
55
- // and before RSVP deadlines: none; before staff RSVP emails: on
56
- if (out.rsvp) out.rsvp = { ...out.rsvp, closesAt: out.rsvp.closesAt ?? null, closeBefore: out.rsvp.closeBefore ?? 0, notifyStaff: out.rsvp.notifyStaff ?? true };
55
+ // and before RSVP deadlines: none; before staff RSVP emails: on; before waitlists: off
56
+ if (out.rsvp) out.rsvp = { ...out.rsvp, closesAt: out.rsvp.closesAt ?? null, closeBefore: out.rsvp.closeBefore ?? 0, notifyStaff: out.rsvp.notifyStaff ?? true, waitlist: out.rsvp.waitlist ?? false };
57
57
  return out;
58
58
  }
59
59
  export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
@@ -11,10 +11,12 @@ import { pageQuery } from '../mobile/http';
11
11
  // Bookings inbox for editor apps: the same status changes as Admin →
12
12
  // Bookings (confirm, decline, cancel; the guest is emailed, webhooks fire),
13
13
  // available to every signed-in owner or editor exactly like the web page.
14
+ // RSVPs on a full date's waitlist are waitlisted (in line) or offered (a spot
15
+ // held for them); cancel takes them off it.
14
16
 
15
17
  export const InboxQuery = z.object({
16
- /** active = pending + confirmed (default); pending requests always sort first */
17
- status: z.enum(['active', 'pending', 'confirmed', 'declined', 'cancelled', 'all']).default('active'),
18
+ /** active = pending + confirmed (default); waitlist = waitlisted + offered; pending requests always sort first */
19
+ status: z.enum(['active', 'pending', 'confirmed', 'waitlist', 'waitlisted', 'offered', 'expired', 'declined', 'cancelled', 'all']).default('active'),
18
20
  from: z.string().datetime({ offset: true }).optional(),
19
21
  ...pageQuery,
20
22
  }).strict();
@@ -26,7 +28,7 @@ export function requireBookings() {
26
28
 
27
29
  export async function inbox(repo: ContentRepository, q: z.infer<typeof InboxQuery>, now = Date.now()) {
28
30
  requireBookings();
29
- const statuses: BookingStatus[] | undefined = q.status === 'all' ? undefined : q.status === 'active' ? ['pending', 'confirmed'] : [q.status];
31
+ const statuses: BookingStatus[] | undefined = q.status === 'all' ? undefined : q.status === 'active' ? ['pending', 'confirmed'] : q.status === 'waitlist' ? ['waitlisted', 'offered'] : [q.status];
30
32
  const [list, resources, events] = await Promise.all([
31
33
  repo.listBookings({ from: q.from ? Date.parse(q.from) : now - 86_400_000, statuses, kinds: ['booking', 'rsvp'] }),
32
34
  repo.listResources(), repo.listEvents(),
@@ -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. Left out = kept
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 (live/words.ts `fill`). `rsvp` rewords a few
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
- export type GuestMail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'updated' | 'rescheduled' | 'reminder' | 'link';
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}' },
@@ -1,8 +1,9 @@
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, EmailLinkError } from './templates';
5
- import { formatDate } from '../dates';
4
+ import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, phoneLinkedMail, EmailLinkError } from './templates';
5
+ import { formatDate, siteLang } from '../dates';
6
+ import { I18N } from '../i18n';
6
7
  import { bookingMail } from '../booking/service';
7
8
  import type { EmailJob, Mail } from './types';
8
9
 
@@ -16,6 +17,17 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
16
17
  if (!current || current.createdAt !== payload.issuedAt || current.expiresAt <= Date.now()) return null;
17
18
  return guestCodeMail(job.recipient, settings.siteTitle, decrypt<string>(payload.code, `guest-code:${job.dedupe}`, locals));
18
19
  }
20
+ if (job.kind === 'phone-linked') {
21
+ const p = JSON.parse(job.payload) as { platform: 'ios' | 'android'; at: number; path: string };
22
+ const lang = (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
23
+ let when: string;
24
+ try { when = new Intl.DateTimeFormat(lang, { dateStyle: 'long', timeStyle: 'short', timeZone: settings.timezone || 'UTC' }).format(p.at); }
25
+ catch { when = new Date(p.at).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; }
26
+ // a security notice still goes out without the link while Site URL is unset
27
+ let origin = '';
28
+ try { origin = canonicalOrigin(settings.siteUrl); } catch { /* text names the panel instead */ }
29
+ return phoneLinkedMail(job.recipient, settings.siteTitle, lang, p.platform, when, origin ? `${origin}${p.path}` : '');
30
+ }
19
31
  const origin = canonicalOrigin(settings.siteUrl);
20
32
  if (job.kind === 'booking') {
21
33
  const payload = JSON.parse(job.payload);
@@ -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] : [])],
@@ -128,6 +128,10 @@ export class EmailStore {
128
128
  count=CASE WHEN expires_at <= ? THEN 1 ELSE count+1 END, expires_at=CASE WHEN expires_at <= ? THEN ? ELSE expires_at END RETURNING count`, [key,now+windowMs,now,now,now+windowMs]);
129
129
  return Number(rows[0].count) <= max;
130
130
  }
131
+ /** gives one hit back (an attempt counted up front that turned out fine) */
132
+ async rateRefund(key: string): Promise<void> {
133
+ await (await this.getDriver()).run('UPDATE email_rate_limits SET count=count-1 WHERE key=? AND count>0 AND expires_at>?', [key, Date.now()]);
134
+ }
131
135
  /** true when key's current window already holds max hits (checks without counting) */
132
136
  async rateLimited(key: string, max: number): Promise<boolean> {
133
137
  const rows = await (await this.getDriver()).all('SELECT count FROM email_rate_limits WHERE key=? AND expires_at > ?', [key, Date.now()]);
@@ -41,3 +41,23 @@ export function canonicalOrigin(value: string): string {
41
41
  if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new EmailLinkError('Site URL in Settings → General must use HTTPS (HTTP is allowed for localhost), without a path, credentials or query.');
42
42
  return url.origin;
43
43
  }
44
+ // "A phone was linked" (lib/push/staff.ts): a security notice to the staff
45
+ // member, in the site's main language (usted in Spanish, like other emails)
46
+ const PHONE_LINKED: Record<string, { subject: string; intro: (phone: string, site: string, when: string) => string; notYou: string; button: string; noLink: string; ios: string; android: string }> = {
47
+ en: { subject: 'A phone was linked to your account', intro: (p, s, w) => `A phone (${p}) was linked to your account at ${s} on ${w}. It gets the test notifications you send from the admin.`,
48
+ notYou: 'If this wasn’t you, remove it in the admin', button: 'Test phones', noLink: 'If this wasn’t you, remove it in the admin under Test phones.', ios: 'iPhone or iPad', android: 'Android' },
49
+ es: { subject: 'Se vinculó un teléfono a su cuenta', intro: (p, s, w) => `Se vinculó un teléfono (${p}) a su cuenta de ${s} el ${w}. Recibe las notificaciones de prueba que usted envía desde el panel.`,
50
+ notYou: 'Si no fue usted, quítelo en el panel', button: 'Teléfonos de prueba', noLink: 'Si no fue usted, quítelo en el panel, en Teléfonos de prueba.', ios: 'iPhone o iPad', android: 'Android' },
51
+ fr: { subject: 'Un téléphone a été associé à votre compte', intro: (p, s, w) => `Un téléphone (${p}) a été associé à votre compte sur ${s} le ${w}. Il reçoit les notifications de test que vous envoyez depuis l’administration.`,
52
+ notYou: 'Si ce n’était pas vous, retirez-le dans l’administration', button: 'Téléphones de test', noLink: 'Si ce n’était pas vous, retirez-le dans l’administration, sous Téléphones de test.', ios: 'iPhone ou iPad', android: 'Android' },
53
+ de: { subject: 'Ein Telefon wurde mit Ihrem Konto verknüpft', intro: (p, s, w) => `Ein Telefon (${p}) wurde am ${w} mit Ihrem Konto bei ${s} verknüpft. Es erhält die Testbenachrichtigungen, die Sie im Adminbereich senden.`,
54
+ notYou: 'Wenn Sie das nicht waren, entfernen Sie es im Adminbereich', button: 'Testtelefone', noLink: 'Wenn Sie das nicht waren, entfernen Sie es im Adminbereich unter Testtelefone.', ios: 'iPhone oder iPad', android: 'Android' },
55
+ pt: { subject: 'Um telefone foi vinculado à sua conta', intro: (p, s, w) => `Um telefone (${p}) foi vinculado à sua conta em ${s} em ${w}. Ele recebe as notificações de teste que você envia pelo painel.`,
56
+ notYou: 'Se não foi você, remova-o no painel', button: 'Telefones de teste', noLink: 'Se não foi você, remova-o no painel, em Telefones de teste.', ios: 'iPhone ou iPad', android: 'Android' },
57
+ };
58
+ export function phoneLinkedMail(to: string, site: string, lang: string, platform: 'ios' | 'android', when: string, url: string): Mail {
59
+ const w = PHONE_LINKED[lang.toLowerCase().split('-')[0]] ?? PHONE_LINKED.en;
60
+ const intro = w.intro(platform === 'ios' ? w.ios : w.android, site, when);
61
+ return { to, subject: `${w.subject} · ${site}`, text: [w.subject, '', intro, '', url ? `${w.notYou}: ${url}` : w.noLink].join('\n'),
62
+ 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:24px">${escape(w.subject)}</h1><p>${escape(intro)}</p>${url ? `<p>${escape(w.notYou)}:</p><p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(w.button)}</a></p>` : `<p>${escape(w.noLink)}</p>`}</div></body></html>` };
63
+ }
@@ -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({
@@ -32,8 +32,8 @@ export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacy
32
32
  });
33
33
  if (f.push.enabled) rows.push({
34
34
  data: 'Push token, platform, language, chosen topics and Android notification channels', when: 'When someone turns on notifications',
35
- apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user. Not used for tracking.',
36
- google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional.',
35
+ apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user (to a staff member’s identity only when they link their own phone for test notifications). Not used for tracking.',
36
+ google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional. Linked to a staff member’s account only when they link their own phone for test notifications.',
37
37
  });
38
38
  rows.push({
39
39
  data: 'IP address', when: 'Every request (rate limiting only)',
@@ -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: [
@@ -52,7 +52,7 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
52
52
  '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
53
  ] });
54
54
  if (f.push.enabled) s.push({ id: 'push', title: 'Our app and notifications', text: [
55
- 'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language, the topics you choose and, on Android, which notification channels the app created (such as one for urgent notices). We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging.',
55
+ 'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language, the topics you choose and, on Android, which notification channels the app created (such as one for urgent notices). We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging. Our staff can link their own phone to their account to receive test notifications; that link is removed when they remove it or turn notifications off.',
56
56
  `You choose the topics in the app and can change them at any time; we never add a topic without asking you. ${f.announcements ? 'Urgent announcements, such as cancellations, go to every device with notifications turned on, whatever topics it chose.' : 'Important notices, such as cancellations, may go to every device with notifications turned on, whatever topics it chose.'} To stop, turn notifications off in the app or in your phone’s settings. Turning them off in the app removes your device from our list right away. Devices that haven’t opened the app for ${days(f.push.retentionDays)}, and devices whose app was removed, are deleted automatically. We keep a record of each notification sent (its text, link and number of recipients, not who received it) for one year. The app works fully without notifications.`,
57
57
  ] });
58
58
  if (f.email) s.push({ id: 'email', title: 'Email', text: [
@@ -43,7 +43,7 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
43
43
  ] },
44
44
  ];
45
45
  if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Reservaciones y registros' : 'Registros a eventos', text: [
46
- `Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}. 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: [
@@ -53,7 +53,7 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
53
53
  '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
54
  ] });
55
55
  if (f.push.enabled) s.push({ id: 'push', title: 'Nuestra app y las notificaciones', text: [
56
- 'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma, los temas que elija y, en Android, qué canales de notificación creó la app (por ejemplo, uno para avisos urgentes). Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging.',
56
+ 'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma, los temas que elija y, en Android, qué canales de notificación creó la app (por ejemplo, uno para avisos urgentes). Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging. Las personas de nuestro equipo pueden vincular su propio teléfono a su cuenta del sitio para recibir notificaciones de prueba; ese vínculo se elimina cuando lo quitan o desactivan las notificaciones.',
57
57
  `Usted elige los temas en la app y puede cambiarlos cuando quiera; nunca agregamos un tema sin preguntarle. ${f.announcements ? 'Los anuncios urgentes, como una cancelación, llegan a todos los dispositivos con las notificaciones activadas, sin importar los temas que eligieron.' : 'Los avisos importantes, como una cancelación, pueden llegar a todos los dispositivos con las notificaciones activadas, sin importar los temas que eligieron.'} Para dejar de recibirlas, desactive las notificaciones en la app o en los ajustes de su teléfono. Al desactivarlas en la app, su dispositivo se elimina de nuestra lista de inmediato. Los dispositivos que no han abierto la app en ${days(f.push.retentionDays)}, y aquellos en los que se borró la app, se eliminan automáticamente. Guardamos un registro de cada notificación enviada (su texto, su enlace y el número de destinatarios, no quiénes la recibieron) durante un año. La app funciona por completo sin notificaciones.`,
58
58
  ] });
59
59
  if (f.email) s.push({ id: 'email', title: 'Correo electrónico', 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
- 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() }).strict(),
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;
@@ -44,7 +44,8 @@ async function requireGuest(c: PublicContext) {
44
44
  }
45
45
  const emailField = z.string().trim().toLowerCase().max(254).refine(validAddress, 'Enter a valid email address.');
46
46
  const hash = (email: string, code: string) => sha256Hex(`guest-code:${email}:${code}`);
47
- function sixDigits() {
47
+ /** A random 6-digit code (guest sign-in, staff phone links). */
48
+ export function sixDigits() {
48
49
  // rejection sampling keeps every code equally likely
49
50
  const buf = new Uint32Array(1);
50
51
  do crypto.getRandomValues(buf); while (buf[0] >= 4_294_000_000);
@@ -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
- function view(m: Managed, booking = m.booking) {
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
- export const getManaged = async (c: PublicContext) => view(await managed(c));
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();
@@ -10,7 +10,7 @@ import { searchQuery } from './search';
10
10
  import { manageAvailabilityQuery, rescheduleInput } from './manage';
11
11
  import { formResponseInput } from './forms';
12
12
  import { codeInput, tokenInput, myBookingsQuery } from './guest';
13
- import { deviceInput, deviceUpdate } from '../push/api';
13
+ import { deviceInput, deviceUpdate, staffLinkInput } from '../push/api';
14
14
  import { announcementsQuery } from '../announcements/api';
15
15
  import { localeOnlyQuery } from './locale';
16
16
  import { pageQuery, queryParams } from './http';
@@ -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],
@@ -127,7 +129,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
127
129
  paths['/api/v1/devices'] = {
128
130
  post: {
129
131
  summary: 'Register or refresh this device for push notifications; requires push',
130
- description: `${pushNote} Call on every app start: it refreshes lastSeenAt (devices unseen for the retention period, default 270 days, are removed). topics replaces the device's topics; omit it to keep them. Stores the token, platform, language and topics only.`,
132
+ description: `${pushNote} Call on every app start: it refreshes lastSeenAt (devices unseen for the retention period, default 270 days, are removed). topics replaces the device's topics; omit it to keep them. Stores the token, platform, language and topics only (plus, for a staff member's own test phone, who linked it). device.staff names that staff member, or is null.`,
131
133
  requestBody: { required: true, content: media(jsonSchema(deviceInput)) },
132
134
  responses: { ...writeErrors, ...Object.fromEntries([200, 201].map(code => [code, { description: code === 201 ? 'Registered.' : 'Refreshed.', headers: noStore, content: media(envelope(ref('DeviceRegistration'))) }])) },
133
135
  },
@@ -140,12 +142,27 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
140
142
  responses: { ...writeErrors, 200: { description: 'Updated.', headers: noStore, content: media(envelope(ref('Device'))) } },
141
143
  },
142
144
  delete: {
143
- summary: 'Opt this device out of push notifications (idempotent); requires push', description: 'Removes the token. Unknown tokens also succeed.',
145
+ summary: 'Opt this device out of push notifications (idempotent); requires push', description: 'Removes the token, and a test-phone link with it. Unknown tokens also succeed.',
144
146
  parameters: [tokenParam],
145
147
  responses: { ...writeErrors, 200: { description: 'Removed.', headers: noStore, content: media(envelope(ref('DeviceRemoved'))) } },
146
148
  },
147
149
  options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
148
150
  };
151
+ const staffNote = 'Requires push; 404 when off. A staff member gets a one-time 6-digit code in the admin (Test phones; 10 minutes, single use) and types it in the app, so this registered device gets their "Send test to my phone" pushes (data.test = true). staff carries their display name only.';
152
+ paths['/api/v1/devices/{token}/staff-link'] = {
153
+ post: {
154
+ summary: 'Link this registered device to a staff member for test pushes; requires push',
155
+ description: `${staffNote} 404 for an unregistered device. A wrong, used or expired code answers 400 with the same message. Tries are capped (429 with Retry-After): 5 per device token and 5 per client address in 10 minutes, and 20 failures across the site void every waiting code.`,
156
+ parameters: [tokenParam], requestBody: { required: true, content: media(jsonSchema(staffLinkInput)) },
157
+ responses: { ...writeErrors, 429: { ...writeErrors[429], description: 'Write limit, or too many link tries (see above).' }, 200: { description: 'Linked.', headers: noStore, content: media(envelope(ref('StaffLink'))) } },
158
+ },
159
+ delete: {
160
+ summary: 'Unlink this device from a staff member (idempotent); requires push', description: `${staffNote} Turning notifications off (DELETE /devices/{token}) also unlinks it.`,
161
+ parameters: [tokenParam],
162
+ responses: { ...writeErrors, 200: { description: 'Unlinked.', headers: noStore, content: media(envelope(ref('StaffLink'))) } },
163
+ },
164
+ options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
165
+ };
149
166
  Object.assign(paths, editorPaths(jsonSchema));
150
167
  return {
151
168
  openapi: '3.1.0', info: { title: 'ACMS Mobile API', version: '1.0.0', description: 'Public API: no authentication, except a booking’s own routes (/bookings/manage/*: its private token in the Booking-Token header) and guest sign-in (/guest/*: a guest token from an emailed code). Owner switches in Admin → Mobile app turn app features off (404). Disabled modules are not registered. Unknown or duplicate query parameters are rejected. locale selects translated words with main-language fallback; unknown locales return 400. Editor API (tag Editor): /api/v1/auth/* and /api/v1/admin/*, registered only when acms({ mobile: { editor } }) is configured; staff sign in through the browser (OAuth 2.0 authorization code + PKCE) and send the device access token as a Bearer header. Cookies never authenticate it.' },