@acms-subir/core 0.3.38 → 0.3.39
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 +11 -0
- package/docs/mobile.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +11 -7
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +11 -7
- package/package.json +1 -1
- package/src/actions/index.ts +2 -1
- package/src/components/EventPage.astro +14 -5
- package/src/components/MobileApp.tsx +44 -23
- package/src/components/editor/EventPanel.tsx +2 -1
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +51 -1
- package/src/components/editor/event/registration.ts +18 -1
- package/src/lib/booking/emails.ts +3 -1
- package/src/lib/booking/service.ts +37 -10
- package/src/lib/calendar/events.ts +11 -0
- package/src/lib/calendar/rsvp-words.ts +31 -2
- package/src/lib/content-service.ts +26 -14
- package/src/lib/drafts.ts +2 -0
- package/src/lib/editor/documents.ts +3 -1
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +10 -1
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/http.ts +1 -1
- package/src/lib/mobile/openapi.ts +1 -1
- package/src/lib/mobile/rsvp.ts +14 -8
- package/src/lib/mobile/schemas.ts +5 -2
- package/src/lib/push/api.ts +7 -8
- package/src/lib/push/service.ts +21 -16
- package/src/lib/push/types.ts +18 -8
- package/src/lib/repo/sql.ts +3 -0
- package/src/lib/repo/types.ts +3 -2
- package/src/lib/schema.ts +5 -1
- package/src/pages/events/[slug].astro +10 -3
- package/src/styles/editor.css +4 -0
- package/src/styles/mobile-app.css +7 -0
- package/src/styles/site.css +3 -0
- package/src/theme-sdk/index.ts +1 -1
|
@@ -154,6 +154,17 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
|
|
|
154
154
|
return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// When RSVPs to one date close (UTC ms), or null when they stay open until it
|
|
158
|
+
// ends: the event's closesAt (wall time in its zone, one instant for every
|
|
159
|
+
// date), else closeBefore minutes before this date's start. A deadline at or
|
|
160
|
+
// after the end isn't one.
|
|
161
|
+
export function rsvpDeadline(o: Pick<Occurrence, 'event' | 'start' | 'end'>, settings: Pick<Settings, 'timezone'>): number | null {
|
|
162
|
+
const { closesAt, closeBefore } = o.event.rsvp;
|
|
163
|
+
const w = parseWall(closesAt);
|
|
164
|
+
const at = w && !w.dateOnly ? wallToMs(w, eventTimeZone(o.event, settings)) : closeBefore > 0 ? o.start - closeBefore * 60_000 : null;
|
|
165
|
+
return at !== null && at < o.end ? at : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
157
168
|
// The occurrence of a series with this key (null when the series has no
|
|
158
169
|
// such date: never generated, skipped by exdates, or the series moved)
|
|
159
170
|
export function findOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): Occurrence | null {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Words of the built-in RSVP: the event page's RSVP block, the RSVP's own
|
|
2
2
|
// page (book/manage) and the app's default button (GET /events/:slug/rsvp).
|
|
3
3
|
// The same wording as the app's RSVP panel (packages/native strings.ts).
|
|
4
|
-
// Plain strings with {count}, {email} and {
|
|
5
|
-
// `fill`); of a pair (`spotLeft`/`spotsLeft`), the first is for 1 and the
|
|
4
|
+
// Plain strings with {count}, {email}, {title}, {date} and {time} slots
|
|
5
|
+
// (live/words.ts `fill`); of a pair (`spotLeft`/`spotsLeft`), the first is for 1 and the
|
|
6
6
|
// second for any other number. The event's own form title, questions and
|
|
7
7
|
// button keep their words. Other languages fall back to English.
|
|
8
8
|
export interface RsvpStrings {
|
|
@@ -21,6 +21,12 @@ export interface RsvpStrings {
|
|
|
21
21
|
requestSeat: string;
|
|
22
22
|
full: string;
|
|
23
23
|
closed: string;
|
|
24
|
+
/** the event's RSVP deadline ({date} and {time}: deadlineParts): before, and once it passed (under `closed`) */
|
|
25
|
+
closesOn: string;
|
|
26
|
+
closedOn: string;
|
|
27
|
+
/** the address already has an RSVP for this date (nothing else about it is shown) */
|
|
28
|
+
duplicate: string;
|
|
29
|
+
duplicateNote: string;
|
|
24
30
|
saveError: string;
|
|
25
31
|
/** the RSVP's own page */
|
|
26
32
|
booked: string;
|
|
@@ -53,6 +59,8 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
53
59
|
personGoing: '{count} going', peopleGoing: '{count} going', spotLeft: '{count} spot left', spotsLeft: '{count} spots left',
|
|
54
60
|
saveSeat: 'Save my seat', nameField: 'Your name', emailField: 'Email', seatsField: 'Seats', confirmSeat: 'Confirm my seat', requestSeat: 'Request a seat',
|
|
55
61
|
full: 'This event is full.', closed: 'RSVPs are closed.', saveError: 'Couldn’t save your RSVP. Please try again.',
|
|
62
|
+
closesOn: 'RSVPs close {date} at {time}', closedOn: 'They closed on {date}. Questions? Contact us.',
|
|
63
|
+
duplicate: 'You already have an RSVP for this date.', duplicateNote: 'We sent you an email with a link to change it. Need more seats? Change it there.',
|
|
56
64
|
booked: 'You’re booked', yours: 'Your booking', requestSent: 'Request sent', awaitingApproval: 'Waiting for approval', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
|
|
57
65
|
emailWhenApproved: 'We’ll email {email} once it’s approved.', confirmationOnItsWay: 'A confirmation is on its way to {email}.',
|
|
58
66
|
declined: 'Sorry, the organiser couldn’t confirm it.', cancelledText: 'This booking is cancelled.',
|
|
@@ -65,6 +73,8 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
65
73
|
personGoing: '{count} va', peopleGoing: '{count} van', spotLeft: '{count} lugar disponible', spotsLeft: '{count} lugares disponibles',
|
|
66
74
|
saveSeat: 'Apartar mi lugar', nameField: 'Tu nombre', emailField: 'Correo electrónico', seatsField: 'Lugares', confirmSeat: 'Confirmar mi lugar', requestSeat: 'Solicitar un lugar',
|
|
67
75
|
full: 'Este evento está lleno.', closed: 'Las confirmaciones están cerradas.', saveError: 'No se pudo guardar tu confirmación. Inténtalo de nuevo.',
|
|
76
|
+
closesOn: 'Las confirmaciones cierran el {date}, {time}', closedOn: 'Cerraron el {date}. ¿Preguntas? Contáctanos.',
|
|
77
|
+
duplicate: 'Ya tienes una confirmación para esta fecha.', duplicateNote: 'Te enviamos un correo con un enlace para cambiarla. ¿Necesitas más lugares? Cámbiala ahí.',
|
|
68
78
|
booked: 'Ya tienes tu lugar', yours: 'Tu confirmación', requestSent: 'Solicitud enviada', awaitingApproval: 'En espera de aprobación', notAvailable: 'No se pudo confirmar', cancelledTitle: 'Confirmación cancelada',
|
|
69
79
|
emailWhenApproved: 'Te escribiremos a {email} cuando se apruebe.', confirmationOnItsWay: 'Te enviamos la confirmación a {email}.',
|
|
70
80
|
declined: 'Lo sentimos, el organizador no pudo confirmarla.', cancelledText: 'Esta confirmación se canceló.',
|
|
@@ -77,6 +87,8 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
77
87
|
personGoing: '{count} participant', peopleGoing: '{count} participants', spotLeft: '{count} place restante', spotsLeft: '{count} places restantes',
|
|
78
88
|
saveSeat: 'Réserver ma place', nameField: 'Votre nom', emailField: 'E-mail', seatsField: 'Places', confirmSeat: 'Confirmer ma place', requestSeat: 'Demander une place',
|
|
79
89
|
full: 'Cet événement est complet.', closed: 'Les inscriptions sont closes.', saveError: 'Impossible d’enregistrer votre inscription. Veuillez réessayer.',
|
|
90
|
+
closesOn: 'Les inscriptions ferment le {date} à {time}', closedOn: 'Elles ont fermé le {date}. Des questions ? Contactez-nous.',
|
|
91
|
+
duplicate: 'Vous avez déjà une inscription pour cette date.', duplicateNote: 'Nous vous avons envoyé un e-mail avec un lien pour la modifier. Besoin de plus de places ? Modifiez-la depuis ce lien.',
|
|
80
92
|
booked: 'Votre place est réservée', yours: 'Votre inscription', requestSent: 'Demande envoyée', awaitingApproval: 'En attente de validation', notAvailable: 'Non confirmée', cancelledTitle: 'Inscription annulée',
|
|
81
93
|
emailWhenApproved: 'Nous écrirons à {email} dès qu’elle sera validée.', confirmationOnItsWay: 'Une confirmation est en route vers {email}.',
|
|
82
94
|
declined: 'Désolés, l’organisateur n’a pas pu la confirmer.', cancelledText: 'Cette inscription est annulée.',
|
|
@@ -89,6 +101,8 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
89
101
|
personGoing: '{count} Zusage', peopleGoing: '{count} Zusagen', spotLeft: '{count} Platz frei', spotsLeft: '{count} Plätze frei',
|
|
90
102
|
saveSeat: 'Platz reservieren', nameField: 'Ihr Name', emailField: 'E-Mail', seatsField: 'Plätze', confirmSeat: 'Platz bestätigen', requestSeat: 'Platz anfragen',
|
|
91
103
|
full: 'Diese Veranstaltung ist ausgebucht.', closed: 'Anmeldungen sind geschlossen.', saveError: 'Ihre Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
|
|
104
|
+
closesOn: 'Anmeldung bis {date}, {time}', closedOn: 'Die Anmeldung endete am {date}. Fragen? Kontaktieren Sie uns.',
|
|
105
|
+
duplicate: 'Sie haben für diesen Termin schon eine Anmeldung.', duplicateNote: 'Wir haben Ihnen eine E-Mail mit einem Link zum Ändern geschickt. Mehr Plätze nötig? Ändern Sie sie dort.',
|
|
92
106
|
booked: 'Ihr Platz ist reserviert', yours: 'Ihre Anmeldung', requestSent: 'Anfrage gesendet', awaitingApproval: 'Wartet auf Freigabe', notAvailable: 'Nicht bestätigt', cancelledTitle: 'Anmeldung storniert',
|
|
93
107
|
emailWhenApproved: 'Wir schreiben an {email}, sobald sie freigegeben ist.', confirmationOnItsWay: 'Eine Bestätigung ist unterwegs an {email}.',
|
|
94
108
|
declined: 'Leider konnte der Veranstalter sie nicht bestätigen.', cancelledText: 'Diese Anmeldung ist storniert.',
|
|
@@ -101,6 +115,8 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
101
115
|
personGoing: '{count} confirmado', peopleGoing: '{count} confirmados', spotLeft: '{count} vaga disponível', spotsLeft: '{count} vagas disponíveis',
|
|
102
116
|
saveSeat: 'Reservar minha vaga', nameField: 'Seu nome', emailField: 'E-mail', seatsField: 'Vagas', confirmSeat: 'Confirmar minha vaga', requestSeat: 'Solicitar uma vaga',
|
|
103
117
|
full: 'Este evento está lotado.', closed: 'As inscrições estão encerradas.', saveError: 'Não foi possível salvar sua inscrição. Tente novamente.',
|
|
118
|
+
closesOn: 'As inscrições se encerram em {date}, {time}', closedOn: 'Elas se encerraram em {date}. Dúvidas? Fale com a gente.',
|
|
119
|
+
duplicate: 'Você já tem uma inscrição para esta data.', duplicateNote: 'Enviamos um e-mail com um link para alterá-la. Precisa de mais vagas? Altere por lá.',
|
|
104
120
|
booked: 'Sua vaga está reservada', yours: 'Sua inscrição', requestSent: 'Solicitação enviada', awaitingApproval: 'Aguardando aprovação', notAvailable: 'Não confirmada', cancelledTitle: 'Inscrição cancelada',
|
|
105
121
|
emailWhenApproved: 'Vamos escrever para {email} assim que for aprovada.', confirmationOnItsWay: 'Uma confirmação está a caminho de {email}.',
|
|
106
122
|
declined: 'Desculpe, o organizador não pôde confirmá-la.', cancelledText: 'Esta inscrição foi cancelada.',
|
|
@@ -119,3 +135,16 @@ export function aroundCount(count: number, one: string, other: string): [string,
|
|
|
119
135
|
const [before, after = ''] = (count === 1 ? one : other).split('{count}');
|
|
120
136
|
return [before, after];
|
|
121
137
|
}
|
|
138
|
+
|
|
139
|
+
/** A deadline's date and time in the event's zone for closesOn/closedOn: { date: 'Fri, Oct 24', time: '9 PM' } (minutes only when it has some). */
|
|
140
|
+
export function deadlineParts(at: number, tz: string, locale: string): { date: string; time: string } {
|
|
141
|
+
const f = (o: Intl.DateTimeFormatOptions) => {
|
|
142
|
+
try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: tz }); }
|
|
143
|
+
catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); }
|
|
144
|
+
};
|
|
145
|
+
const time = f({ hour: 'numeric', minute: '2-digit' });
|
|
146
|
+
const hour = f({ hour: 'numeric' }).format(at);
|
|
147
|
+
// "9 PM", "21 Uhr"; a bare "21" (Portuguese) keeps its minutes
|
|
148
|
+
const whole = time.formatToParts(at).find((p) => p.type === 'minute')?.value === '00' && /\D/.test(hour);
|
|
149
|
+
return { date: f({ weekday: 'short', month: 'short', day: 'numeric' }).format(at), time: whole ? hour : time.format(at) };
|
|
150
|
+
}
|
|
@@ -6,7 +6,7 @@ import { ApiError } from './api';
|
|
|
6
6
|
import { deniedMessage } from './permissions/catalog';
|
|
7
7
|
import { CONTENT, collectionUrl } from './content';
|
|
8
8
|
import { CALENDAR } from './calendar/config';
|
|
9
|
-
import { isValidTimeZone, normalizeEventTimes } from './calendar/events';
|
|
9
|
+
import { formatWall, isValidTimeZone, normalizeEventTimes, parseWall } from './calendar/events';
|
|
10
10
|
import { eventChanged } from './calendar/hooks';
|
|
11
11
|
import { normalizePageLocale } from './i18n';
|
|
12
12
|
import { siteOrigin } from './seo';
|
|
@@ -37,10 +37,12 @@ export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blo
|
|
|
37
37
|
// location (see mergeLocationInput) — older clients send name/address/url only
|
|
38
38
|
// cancelledDates is left out: single dates are cancelled at once through
|
|
39
39
|
// cancelOccurrence/restoreOccurrence (lib/calendar/schedule.ts), never staged
|
|
40
|
-
|
|
40
|
+
// rsvp may leave out the deadline (older clients): the current one stays
|
|
41
|
+
export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location' | 'cancelledDates' | 'rsvp'>> & {
|
|
41
42
|
location?: Partial<EventLocation>;
|
|
43
|
+
rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore'>>;
|
|
42
44
|
};
|
|
43
|
-
type EventClean = Omit<EventSave, 'location'> & { location?: EventLocation };
|
|
45
|
+
type EventClean = Omit<EventSave, 'location' | 'rsvp'> & { location?: EventLocation; rsvp?: CalEvent['rsvp'] };
|
|
44
46
|
|
|
45
47
|
const live = (status: string) => status === 'published' || status === 'scheduled';
|
|
46
48
|
/** publishing, scheduling, unpublishing or applying staged edits needs Publish content */
|
|
@@ -130,18 +132,28 @@ export async function applyPageUpdate(repo: ContentRepository, id: string, hooks
|
|
|
130
132
|
// Validate + normalize an event save. Times are checked as a PAIR against the
|
|
131
133
|
// merged (staged) state so a half-edited range can't slip through.
|
|
132
134
|
export async function cleanEventInput(repo: ContentRepository, data: EventSave, before: CalEvent): Promise<EventClean> {
|
|
133
|
-
const { location, ...rest } = data;
|
|
135
|
+
const { location, rsvp, ...rest } = data;
|
|
134
136
|
const out: EventClean = { ...rest };
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const previous = mergeEventDraft(before).rsvp
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
if (rsvp) {
|
|
138
|
+
// Older editor clients resend the original RSVP shape. Omission must not
|
|
139
|
+
// erase questions or a deadline that were authored in a newer editor.
|
|
140
|
+
const previous = mergeEventDraft(before).rsvp;
|
|
141
|
+
const next: CalEvent['rsvp'] = { ...rsvp, closesAt: rsvp.closesAt === undefined ? previous.closesAt ?? null : rsvp.closesAt || null, closeBefore: rsvp.closeBefore ?? previous.closeBefore ?? 0 };
|
|
142
|
+
if (!Object.hasOwn(rsvp, 'form') && previous.form) next.form = previous.form;
|
|
143
|
+
if (next.form !== undefined) {
|
|
144
|
+
const form = FormDefinition.safeParse(next.form);
|
|
145
|
+
if (!form.success) throw new ApiError(400, form.error.issues[0]?.message || 'Invalid registration form.');
|
|
146
|
+
next.form = form.data;
|
|
147
|
+
}
|
|
148
|
+
if (next.closesAt !== null) {
|
|
149
|
+
const w = parseWall(next.closesAt);
|
|
150
|
+
if (!w || w.dateOnly) throw new ApiError(400, 'The RSVP deadline must be a date and time (YYYY-MM-DDTHH:mm).');
|
|
151
|
+
next.closesAt = formatWall(w);
|
|
152
|
+
}
|
|
153
|
+
if (!Number.isInteger(next.closeBefore) || next.closeBefore < 0 || next.closeBefore > 60 * 1440) {
|
|
154
|
+
throw new ApiError(400, 'The RSVP deadline can be at most 60 days before each date (closeBefore: 0–86400 minutes).');
|
|
155
|
+
}
|
|
156
|
+
out.rsvp = next;
|
|
145
157
|
}
|
|
146
158
|
if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
|
|
147
159
|
if (out.slug !== undefined) {
|
package/src/lib/drafts.ts
CHANGED
|
@@ -52,6 +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
|
|
56
|
+
if (out.rsvp) out.rsvp = { ...out.rsvp, closesAt: out.rsvp.closesAt ?? null, closeBefore: out.rsvp.closeBefore ?? 0 };
|
|
55
57
|
return out;
|
|
56
58
|
}
|
|
57
59
|
export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
|
|
@@ -160,7 +160,9 @@ export const EventFields = z.object({ ...common, excerpt: text(1000), tags: z.ar
|
|
|
160
160
|
}).partial({ lat: true, lng: true, placeId: true, showMap: true, mapImage: true, mapImageDark: true }).strict(),
|
|
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. Left out = kept
|
|
164
|
+
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),
|
|
165
|
+
closesAt: text(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), form: FormDefinition.optional() }).strict(),
|
|
164
166
|
}).partial().strict();
|
|
165
167
|
export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
|
|
166
168
|
|
|
@@ -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, 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, form: FormDefinition.optional() }),
|
|
51
51
|
}).nullable(),
|
|
52
52
|
languages: z.array(str),
|
|
53
53
|
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// {url} and {hours} slots (live/words.ts `fill`). `rsvp` rewords a few
|
|
7
7
|
// messages for a seat at an event (English says "booking" for both). Staff
|
|
8
8
|
// emails use the site's main language. Other languages fall back to English.
|
|
9
|
-
export type GuestMail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'updated' | 'rescheduled' | 'reminder';
|
|
9
|
+
export type GuestMail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'updated' | 'rescheduled' | 'reminder' | 'link';
|
|
10
10
|
export interface MailCopy { subject: string; heading: string; intro: string }
|
|
11
11
|
|
|
12
12
|
export interface BookingStrings {
|
|
@@ -54,6 +54,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
54
54
|
updated: { subject: 'Updated: {title}', heading: 'Booking details updated', intro: 'Please review the latest details. Use the attached calendar file to update your calendar.' },
|
|
55
55
|
rescheduled: { subject: 'New time: {title} — {when}', heading: 'Booking moved', intro: 'Your booking has a new time. Import the attached calendar file to update your calendar.' },
|
|
56
56
|
reminder: { subject: 'Reminder: {title} — {when}', heading: 'Coming up soon', intro: 'A quick reminder about your booking.' },
|
|
57
|
+
link: { subject: 'Your booking: {title}', heading: 'You already have this booking', intro: 'We got another request for this date with your email address, so nothing changed. To see or change your booking, use the button below.' },
|
|
57
58
|
},
|
|
58
59
|
manage: 'View or change your booking', rsvp: { manage: 'View or change your booking' },
|
|
59
60
|
what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
|
|
@@ -77,6 +78,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
77
78
|
updated: { subject: 'Actualizada: {title}', heading: 'Cambiaron los detalles de su reservación', intro: 'Revise los detalles más recientes. Use el archivo de calendario adjunto para actualizar su calendario.' },
|
|
78
79
|
rescheduled: { subject: 'Nuevo horario: {title} — {when}', heading: 'Su reservación cambió de horario', intro: 'Su reservación tiene un nuevo horario. Importe el archivo de calendario adjunto para actualizar su calendario.' },
|
|
79
80
|
reminder: { subject: 'Recordatorio: {title} — {when}', heading: 'Ya casi es la fecha', intro: 'Un breve recordatorio de su reservación.' },
|
|
81
|
+
link: { subject: 'Su reservación: {title}', heading: 'Ya tiene esta reservación', intro: 'Recibimos otra solicitud para este horario con su correo, así que no cambió nada. Para ver o cambiar su reservación, use el botón de abajo.' },
|
|
80
82
|
},
|
|
81
83
|
manage: 'Ver o cambiar su reservación',
|
|
82
84
|
rsvp: {
|
|
@@ -86,6 +88,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
86
88
|
cancelled: { subject: 'Cancelado: {title}', heading: 'Confirmación cancelada', intro: 'Esta confirmación se canceló.' },
|
|
87
89
|
updated: { subject: 'Actualizado: {title}', heading: 'Cambiaron los detalles' },
|
|
88
90
|
reminder: { intro: 'Un breve recordatorio: tiene un lugar apartado.' },
|
|
91
|
+
link: { subject: 'Su confirmación para {title}', heading: 'Ya tiene una confirmación', intro: 'Recibimos otra confirmación para esta fecha con su correo, así que no cambió nada. Para cambiar la suya, o agregar lugares, use el botón de abajo.' },
|
|
89
92
|
},
|
|
90
93
|
what: 'Qué', when: 'Cuándo', where: 'Dónde', seats: 'Lugares', reference: 'Referencia {ref}', icsManage: 'Ver o cambiar: {url}',
|
|
91
94
|
lateDecline: 'No pudimos confirmar esta reservación a tiempo. Por favor, elija otro horario.',
|
|
@@ -108,6 +111,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
108
111
|
updated: { subject: 'Mise à jour : {title}', heading: 'Détails de la réservation mis à jour', intro: 'Veuillez vérifier les derniers détails. Utilisez le fichier de calendrier joint pour mettre à jour votre calendrier.' },
|
|
109
112
|
rescheduled: { subject: 'Nouvel horaire : {title} — {when}', heading: 'Réservation déplacée', intro: 'Votre réservation a un nouvel horaire. Importez le fichier de calendrier joint pour mettre à jour votre calendrier.' },
|
|
110
113
|
reminder: { subject: 'Rappel : {title} — {when}', heading: 'C’est bientôt', intro: 'Un petit rappel de votre réservation.' },
|
|
114
|
+
link: { subject: 'Votre réservation : {title}', heading: 'Vous avez déjà cette réservation', intro: 'Nous avons reçu une autre demande pour ce créneau avec votre adresse e-mail, donc rien n’a changé. Pour voir ou modifier votre réservation, utilisez le bouton ci-dessous.' },
|
|
111
115
|
},
|
|
112
116
|
manage: 'Voir ou modifier votre réservation',
|
|
113
117
|
rsvp: {
|
|
@@ -117,6 +121,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
117
121
|
cancelled: { heading: 'Inscription annulée', intro: 'Cette inscription a été annulée.' },
|
|
118
122
|
updated: { heading: 'Détails mis à jour' },
|
|
119
123
|
reminder: { intro: 'Un petit rappel de votre inscription.' },
|
|
124
|
+
link: { subject: 'Votre inscription : {title}', heading: 'Vous avez déjà une inscription', intro: 'Nous avons reçu une autre inscription pour cette date avec votre adresse e-mail, donc rien n’a changé. Pour modifier la vôtre ou ajouter des places, utilisez le bouton ci-dessous.' },
|
|
120
125
|
},
|
|
121
126
|
what: 'Quoi', when: 'Quand', where: 'Où', seats: 'Places', reference: 'Référence {ref}', icsManage: 'Gérer : {url}',
|
|
122
127
|
lateDecline: 'Nous n’avons pas pu confirmer cette réservation à temps. Veuillez choisir un autre horaire.',
|
|
@@ -139,6 +144,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
139
144
|
updated: { subject: 'Aktualisiert: {title}', heading: 'Buchungsdetails aktualisiert', intro: 'Bitte prüfen Sie die aktuellen Details. Mit der angehängten Kalenderdatei aktualisieren Sie Ihren Kalender.' },
|
|
140
145
|
rescheduled: { subject: 'Neuer Termin: {title} — {when}', heading: 'Buchung verschoben', intro: 'Ihre Buchung hat einen neuen Termin. Importieren Sie die angehängte Kalenderdatei, um Ihren Kalender zu aktualisieren.' },
|
|
141
146
|
reminder: { subject: 'Erinnerung: {title} — {when}', heading: 'Bald ist es so weit', intro: 'Eine kurze Erinnerung an Ihre Buchung.' },
|
|
147
|
+
link: { subject: 'Ihre Buchung: {title}', heading: 'Sie haben diese Buchung schon', intro: 'Wir haben eine weitere Anfrage für diesen Termin mit Ihrer E-Mail-Adresse erhalten, es hat sich also nichts geändert. Über den Button unten sehen oder ändern Sie Ihre Buchung.' },
|
|
142
148
|
},
|
|
143
149
|
manage: 'Buchung ansehen oder ändern',
|
|
144
150
|
rsvp: {
|
|
@@ -148,6 +154,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
148
154
|
cancelled: { heading: 'Anmeldung storniert', intro: 'Diese Anmeldung wurde storniert.' },
|
|
149
155
|
updated: { heading: 'Details aktualisiert' },
|
|
150
156
|
reminder: { intro: 'Eine kurze Erinnerung an Ihre Anmeldung.' },
|
|
157
|
+
link: { subject: 'Ihre Anmeldung: {title}', heading: 'Sie sind schon angemeldet', intro: 'Wir haben eine weitere Anmeldung für diesen Termin mit Ihrer E-Mail-Adresse erhalten, es hat sich also nichts geändert. Über den Button unten ändern Sie Ihre Anmeldung oder fügen Plätze hinzu.' },
|
|
151
158
|
},
|
|
152
159
|
what: 'Was', when: 'Wann', where: 'Wo', seats: 'Plätze', reference: 'Referenz {ref}', icsManage: 'Verwalten: {url}',
|
|
153
160
|
lateDecline: 'Wir konnten diese Buchung nicht rechtzeitig bestätigen. Bitte wählen Sie einen anderen Termin.',
|
|
@@ -170,6 +177,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
170
177
|
updated: { subject: 'Atualizada: {title}', heading: 'Detalhes da reserva atualizados', intro: 'Confira os detalhes mais recentes. Use o arquivo de calendário anexo para atualizar seu calendário.' },
|
|
171
178
|
rescheduled: { subject: 'Novo horário: {title} — {when}', heading: 'Reserva remarcada', intro: 'Sua reserva tem um novo horário. Importe o arquivo de calendário anexo para atualizar seu calendário.' },
|
|
172
179
|
reminder: { subject: 'Lembrete: {title} — {when}', heading: 'Está chegando', intro: 'Um lembrete rápido sobre sua reserva.' },
|
|
180
|
+
link: { subject: 'Sua reserva: {title}', heading: 'Você já tem esta reserva', intro: 'Recebemos outro pedido para este horário com seu e-mail, então nada mudou. Para ver ou alterar sua reserva, use o botão abaixo.' },
|
|
173
181
|
},
|
|
174
182
|
manage: 'Ver ou alterar sua reserva',
|
|
175
183
|
rsvp: {
|
|
@@ -179,6 +187,7 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
179
187
|
cancelled: { heading: 'Inscrição cancelada', intro: 'Esta inscrição foi cancelada.' },
|
|
180
188
|
updated: { heading: 'Detalhes atualizados' },
|
|
181
189
|
reminder: { intro: 'Um lembrete rápido sobre sua inscrição.' },
|
|
190
|
+
link: { subject: 'Sua inscrição em {title}', heading: 'Você já tem uma inscrição', intro: 'Recebemos outra inscrição para esta data com seu e-mail, então nada mudou. Para alterar a sua, ou adicionar vagas, use o botão abaixo.' },
|
|
182
191
|
},
|
|
183
192
|
what: 'O quê', when: 'Quando', where: 'Onde', seats: 'Vagas', reference: 'Referência {ref}', icsManage: 'Gerenciar: {url}',
|
|
184
193
|
lateDecline: 'Não conseguimos confirmar esta reserva a tempo. Por favor, escolha outro horário.',
|
|
@@ -66,7 +66,7 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
66
66
|
if (body.seats > (event.rsvp.maxSeats || 1)) throw new ApiError(400, 'Too many seats for one RSVP.');
|
|
67
67
|
// the app must have seen the current questions: a changed form then answers 409, as on the website
|
|
68
68
|
// (closed dates fall through to createRsvp's own answer)
|
|
69
|
-
if (!body.formVersion && rsvpQuestions(event) && !rsvpClosed(occurrence)) throw new ApiError(400, 'This event asks questions. Send formVersion from GET /api/v1/events/{slug}/rsvp with the answers.');
|
|
69
|
+
if (!body.formVersion && rsvpQuestions(event) && !rsvpClosed(occurrence, settings)) throw new ApiError(400, 'This event asks questions. Send formVersion from GET /api/v1/events/{slug}/rsvp with the answers.');
|
|
70
70
|
booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker, source: 'app' }, flow);
|
|
71
71
|
} else {
|
|
72
72
|
const resource = await repo.getResourceBySlug(body.resource);
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function failure(error: unknown) {
|
|
|
33
33
|
const status = error instanceof ApiError || error instanceof BookingError ? error.status : error instanceof z.ZodError ? 400 : 500;
|
|
34
34
|
const codes: Record<number, string> = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited', 502: 'upstream_error' };
|
|
35
35
|
return Response.json({ version: 1, error: {
|
|
36
|
-
code: codes[status] ?? 'internal_error',
|
|
36
|
+
code: (error instanceof BookingError && error.code) || (codes[status] ?? 'internal_error'),
|
|
37
37
|
message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
|
|
38
38
|
...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
|
|
39
39
|
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': String((error as ApiError).retryAfter ?? 60) } : {}),
|
|
@@ -79,7 +79,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
79
79
|
paths['/api/v1/bookings'] = {
|
|
80
80
|
post: {
|
|
81
81
|
summary: 'Create a guest booking or an RSVP, or replay an existing request; RSVPs require calendar, room bookings also bookings',
|
|
82
|
-
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409.',
|
|
82
|
+
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409. 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
83
|
parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
|
|
84
84
|
requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
|
|
85
85
|
responses: { ...errors,
|
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import type { CalEvent } from '../schema';
|
|
3
|
+
import type { CalEvent, Settings } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { answerFields } from '../forms/schema';
|
|
6
|
-
import { eventIsPublic, expandEvents, type Occurrence } from '../calendar/events';
|
|
6
|
+
import { eventIsPublic, expandEvents, rsvpDeadline, type Occurrence } from '../calendar/events';
|
|
7
7
|
import { seatsTaken } from '../booking/service';
|
|
8
8
|
import { localizeDoc } from '../translations';
|
|
9
9
|
import { siteLang } from '../dates';
|
|
@@ -15,8 +15,8 @@ import { localeQuery, mobileLocale } from './locale';
|
|
|
15
15
|
|
|
16
16
|
// RSVP status of one date of an event, for apps: whether it takes RSVPs now,
|
|
17
17
|
// live seats and the questions with their version. The same rules as the
|
|
18
|
-
// event's web page: open until the date ends, no
|
|
19
|
-
// Never cached; seats change with every RSVP. POST /bookings saves one.
|
|
18
|
+
// event's web page: open until the event's deadline or the date ends, no
|
|
19
|
+
// waitlist. Never cached; seats change with every RSVP. POST /bookings saves one.
|
|
20
20
|
|
|
21
21
|
/** a public event (slug or id) and its occurrence starting exactly at `start` */
|
|
22
22
|
export async function rsvpOccurrence(repo: ContentRepository, ref: string, start: string): Promise<Occurrence> {
|
|
@@ -31,11 +31,13 @@ export async function rsvpOccurrence(repo: ContentRepository, ref: string, start
|
|
|
31
31
|
}
|
|
32
32
|
/** the RSVP form when it asks something; apps must then send its version */
|
|
33
33
|
export const rsvpQuestions = (e: CalEvent) => e.rsvp.form && answerFields(e.rsvp.form).length ? e.rsvp.form : null;
|
|
34
|
-
/** why this date takes no RSVPs (seats aside), or null */
|
|
35
|
-
export function rsvpClosed(o: Occurrence, now = Date.now()) {
|
|
34
|
+
/** why this date takes no RSVPs (seats aside), or null: closed = past the event's deadline */
|
|
35
|
+
export function rsvpClosed(o: Occurrence, settings: Pick<Settings, 'timezone'>, now = Date.now()) {
|
|
36
36
|
if (!o.event.rsvp.enabled) return 'disabled' as const;
|
|
37
37
|
if (o.cancelled) return 'cancelled' as const;
|
|
38
38
|
if (o.end <= now) return 'ended' as const;
|
|
39
|
+
const deadline = rsvpDeadline(o, settings);
|
|
40
|
+
if (deadline !== null && deadline <= now) return 'closed' as const;
|
|
39
41
|
return null;
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -48,24 +50,28 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
48
50
|
const q = rsvpQuery.parse(queryParams(url));
|
|
49
51
|
const locale = mobileLocale(q.locale);
|
|
50
52
|
const o = await rsvpOccurrence(repo, slug, q.start);
|
|
53
|
+
const settings = await repo.getSettings();
|
|
51
54
|
const { rsvp } = o.event;
|
|
52
55
|
// pending and confirmed RSVPs hold seats, as in the capacity check
|
|
53
56
|
const going = rsvp.enabled ? await seatsTaken(repo, o.event.id, o.start) : 0;
|
|
54
57
|
const left = rsvp.capacity ? Math.max(0, rsvp.capacity - going) : null;
|
|
55
|
-
const closed = rsvpClosed(o);
|
|
58
|
+
const closed = rsvpClosed(o, settings);
|
|
59
|
+
const deadline = rsvp.enabled ? rsvpDeadline(o, settings) : null;
|
|
56
60
|
const reason = closed ?? (left === 0 ? 'full' as const : null);
|
|
57
61
|
const form = rsvpQuestions(o.event);
|
|
58
62
|
const title = localizeDoc(o.event, locale ?? undefined).title;
|
|
59
63
|
// the built-in button speaks the app's language (the site's own without languages set up)
|
|
60
64
|
let submitLabel = form?.submitLabel ?? '';
|
|
61
65
|
if (form && !submitLabel) {
|
|
62
|
-
const words = rsvpStrings(locale ?? siteLang(
|
|
66
|
+
const words = rsvpStrings(locale ?? siteLang(settings));
|
|
63
67
|
submitLabel = rsvp.autoConfirm ? words.confirmSeat : words.requestSeat;
|
|
64
68
|
}
|
|
65
69
|
return {
|
|
66
70
|
event: { id: o.event.id, slug: o.event.slug, title },
|
|
67
71
|
start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
68
72
|
open: !reason, reason, going, left, capacity: rsvp.capacity,
|
|
73
|
+
// when RSVPs to this date close, if before it ends
|
|
74
|
+
closesAt: deadline === null ? null : new Date(deadline).toISOString(),
|
|
69
75
|
// seats one RSVP may take right now
|
|
70
76
|
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
71
77
|
form: form ? { ...await formJson(form), submitLabel } : null,
|
|
@@ -72,8 +72,10 @@ export const Form = z.object({
|
|
|
72
72
|
export const FormReceipt = z.object({ received: z.literal(true), confirmation: str });
|
|
73
73
|
export const RsvpStatus = z.object({
|
|
74
74
|
event: z.object({ id: str, slug: str, title: str }), start: date, end: date,
|
|
75
|
-
open: bool, reason: z.enum(['disabled', 'ended', 'cancelled', 'full']).nullable(),
|
|
75
|
+
open: bool, reason: z.enum(['disabled', 'ended', 'closed', 'cancelled', 'full']).nullable(),
|
|
76
76
|
going: z.number().int(), left: z.number().int().nullable(), capacity: z.number().int(), maxSeats: z.number().int(), autoConfirm: bool,
|
|
77
|
+
// the event's RSVP deadline for this date (null: open until it ends)
|
|
78
|
+
closesAt: date.nullable(),
|
|
77
79
|
form: Form.omit({ id: true }).nullable(),
|
|
78
80
|
});
|
|
79
81
|
const GuestChanges = z.object({ cancel: bool, reschedule: bool, cutoffHours: z.number().int(), closed: bool });
|
|
@@ -135,6 +137,7 @@ export const LiveStatus = z.object({
|
|
|
135
137
|
display: z.object({ style: z.enum(['poster', 'split', 'row']), bar: bool, header: bool, dock: bool }),
|
|
136
138
|
});
|
|
137
139
|
export const Error = z.object({ version: z.literal(1), error: z.object({
|
|
138
|
-
|
|
140
|
+
// duplicate (409): that email already has an RSVP for the date
|
|
141
|
+
code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'duplicate', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error']),
|
|
139
142
|
message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
|
|
140
143
|
}) });
|
package/src/lib/push/api.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { localeOnlyQuery, mobileLocale } from '../mobile/locale';
|
|
|
12
12
|
import { EXPO_PUSH_BASE, PUSH, requirePush } from './config';
|
|
13
13
|
import { expoOptions } from './expo';
|
|
14
14
|
import { knownTopics, pushTopics, sendNotification, siteOriginOf, type PushRunOptions } from './service';
|
|
15
|
-
import { PushAuto, type PushDevice } from './types';
|
|
15
|
+
import { MAX_CHANGE_RULES, PushAuto, type PushDevice } from './types';
|
|
16
16
|
|
|
17
17
|
export const pushToken = z.string().max(250).regex(/^Expo(?:nent)?PushToken\[[A-Za-z0-9_-]{8,200}\]$/, 'Use an Expo push token such as ExponentPushToken[…].');
|
|
18
18
|
const topicList = z.array(z.string().min(1).max(200).regex(/^[a-z0-9][a-z0-9:_-]*$/i, 'Use topic ids from GET /api/v1/push/topics.')).max(50);
|
|
@@ -155,20 +155,19 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
|
|
|
155
155
|
const collections = new Set(Object.keys(CONTENT.collections));
|
|
156
156
|
const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
|
|
157
157
|
const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
|
|
158
|
-
const changes = input.auto.eventChanges;
|
|
159
|
-
const tags = changes?.tags ?? [];
|
|
160
158
|
const known = await knownTopics(repo);
|
|
161
159
|
const auto = {
|
|
162
160
|
newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
|
|
163
161
|
newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
|
|
164
162
|
startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
|
|
165
163
|
live: { enabled: input.auto.live?.enabled === true },
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
// a rule left without calendars is off: dropped
|
|
165
|
+
eventChanges: CALENDAR.enabled ? (input.auto.eventChanges ?? []).map((r) => ({
|
|
166
|
+
calendars: cals(r.calendars),
|
|
168
167
|
// one of each tag, whatever its case (matching ignores case too)
|
|
169
|
-
tags: tags.filter((t, i) => tags.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i),
|
|
170
|
-
topics: [...new Set(
|
|
171
|
-
}
|
|
168
|
+
tags: r.tags.filter((t, i) => r.tags.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i),
|
|
169
|
+
topics: [...new Set(r.topics)].filter((t) => known.has(t)),
|
|
170
|
+
})).filter((r) => r.calendars.length).slice(0, MAX_CHANGE_RULES) : [],
|
|
172
171
|
};
|
|
173
172
|
await repo.updateSettings({ push: { auto } });
|
|
174
173
|
return { auto };
|
package/src/lib/push/service.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { ANNOUNCEMENTS } from '../announcements/config';
|
|
|
15
15
|
import type { LiveSnapshot } from '../live/types';
|
|
16
16
|
import { PUSH } from './config';
|
|
17
17
|
import { expoOptions, fetchReceipts, isGone, sendMessages, type ExpoMessage, type ExpoOptions } from './expo';
|
|
18
|
-
import { EventSnapshot, type
|
|
18
|
+
import { EventSnapshot, type ChangeRule, type PushNotification, type PushSource, type PushTopic } from './types';
|
|
19
19
|
|
|
20
20
|
const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
|
|
21
21
|
/** automatic notices queued longer ago than this are skipped, never sent late */
|
|
@@ -57,9 +57,9 @@ export async function pushTopics(repo: ContentRepository, locale?: string | null
|
|
|
57
57
|
const out: PushTopic[] = [{ id: 'news', label: w.news, kind: 'general' }];
|
|
58
58
|
if (collections.length > 1) out.push(...collections.map(([key, c]): PushTopic => ({ id: `collection:${key}`, label: c.plural, kind: 'collection' })));
|
|
59
59
|
if (CALENDAR.enabled) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
out.push({ id: 'events', label:
|
|
60
|
+
// event-change notices go to `events` from a rule that names no other topics
|
|
61
|
+
const toEvents = settings.push.auto.eventChanges.some((r) => r.calendars.length && !r.topics.length);
|
|
62
|
+
out.push({ id: 'events', label: toEvents ? w.eventsChanges : w.events, kind: 'general' });
|
|
63
63
|
const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
|
|
64
64
|
if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
|
|
65
65
|
}
|
|
@@ -224,20 +224,24 @@ export async function sendNotification(repo: ContentRepository, input: SendInput
|
|
|
224
224
|
|
|
225
225
|
const listedCalendars = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
|
|
226
226
|
const inRule = (rule: string[], e: CalEvent) => rule.includes('*') || e.calendarIds.some((id) => rule.includes(id));
|
|
227
|
-
//
|
|
228
|
-
function inChangeRule(rule:
|
|
227
|
+
// a change rule: its calendars, and one of its tags when it lists some (any case)
|
|
228
|
+
function inChangeRule(rule: ChangeRule, e: CalEvent) {
|
|
229
229
|
const tags = new Set(rule.tags.map((t) => t.toLowerCase()));
|
|
230
230
|
return inRule(rule.calendars, e) && (!tags.size || e.tags.some((t) => tags.has(t.toLowerCase())));
|
|
231
231
|
}
|
|
232
|
+
// every rule that covers the event adds its topics, or the event's own when it
|
|
233
|
+
// names none: in rule order, once each ([] = no rule covers it)
|
|
234
|
+
function changeAudience(rules: ChangeRule[], e: CalEvent, calendars: Map<string, Calendar>) {
|
|
235
|
+
return [...new Set(rules.filter((r) => inChangeRule(r, e)).flatMap((r) => (r.topics.length ? r.topics : eventTopics(e, calendars))))];
|
|
236
|
+
}
|
|
232
237
|
/**
|
|
233
238
|
* The topics this event's change notices go to (Admin → Mobile app → When an
|
|
234
|
-
* event changes), or [] while
|
|
235
|
-
*
|
|
239
|
+
* event changes), or [] while no rule covers it. Apps show it to promise
|
|
240
|
+
* "we'll let you know if it changes" only when it's true.
|
|
236
241
|
*/
|
|
237
242
|
export function changeTopics(e: CalEvent, settings: Settings, calendars: Map<string, Calendar>): string[] {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return rule.topics.length ? [...rule.topics] : eventTopics(e, calendars);
|
|
243
|
+
if (!PUSH.enabled || !CALENDAR.enabled) return [];
|
|
244
|
+
return changeAudience(settings.push.auto.eventChanges, e, calendars);
|
|
241
245
|
}
|
|
242
246
|
function eventTopics(e: CalEvent, calendars: Map<string, Calendar>) {
|
|
243
247
|
return ['events', ...e.calendarIds.map((id) => calendars.get(id)).filter((c): c is Calendar => c?.visibility === 'public').map((c) => `calendar:${c.slug}`)];
|
|
@@ -355,7 +359,7 @@ export async function queueEventChange(repo: ContentRepository, before: CalEvent
|
|
|
355
359
|
const was = eventSnapshot(before);
|
|
356
360
|
if (JSON.stringify(was) === JSON.stringify(eventSnapshot(after))) return false;
|
|
357
361
|
const [settings, all] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
358
|
-
if (!
|
|
362
|
+
if (!settings.push.auto.eventChanges.some((r) => inChangeRule(r, after)) || !eventIsListed(after, listedCalendars(all))) return false;
|
|
359
363
|
const key = `change:${after.id}`;
|
|
360
364
|
// a change to a date that starts soon waits only a minute (judged from what people last knew)
|
|
361
365
|
const row = await repo.push.queuedChange(key);
|
|
@@ -381,7 +385,7 @@ async function announcedLately(repo: ContentRepository, e: CalEvent, origin: str
|
|
|
381
385
|
return (await repo.announcements.pushedSince(now - HOUR)).some((a) => a.link !== '' && sitePath(a.link, origin) === page);
|
|
382
386
|
}
|
|
383
387
|
|
|
384
|
-
/** `topics`: the
|
|
388
|
+
/** `topics`: the audience of the rules that cover it (changeTopics); [] = the event's topics. */
|
|
385
389
|
export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Settings, calendars: Map<string, Calendar>, topics: string[] = []): SendInput {
|
|
386
390
|
const tz = eventTimeZone(e, settings);
|
|
387
391
|
const siteTz = settings.timezone || 'UTC';
|
|
@@ -448,9 +452,10 @@ export async function processPushQueue(repo: ContentRepository, opts: PushRunOpt
|
|
|
448
452
|
// compared at send time: edits that cancel out, or come too late, send nothing
|
|
449
453
|
const e = await repo.getEvent(row.docId);
|
|
450
454
|
const was = readSnapshot(row.payload);
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
455
|
+
// one notice, to everyone the rules that cover it send to
|
|
456
|
+
const topics = e ? changeAudience(auto.eventChanges, e, calendars) : [];
|
|
457
|
+
const change = e && was && topics.length && eventIsListed(e, calendars) ? eventChange(was, e, settings, now) : null;
|
|
458
|
+
if (e && change && !(await announcedLately(repo, e, run.origin, now))) notice = eventChangeNotice(e, change, settings, calendars, topics);
|
|
454
459
|
}
|
|
455
460
|
if (!notice) { await repo.push.markQueue(row.key, 'skipped', null, now); continue; }
|
|
456
461
|
const n = await sendNotification(repo, notice, run);
|
package/src/lib/push/types.ts
CHANGED
|
@@ -2,6 +2,22 @@ import { z } from 'zod';
|
|
|
2
2
|
|
|
3
3
|
// '*' in a calendars list = every public event
|
|
4
4
|
const keys = z.array(z.string().min(1).max(200)).max(100);
|
|
5
|
+
/** One "when an event changes" rule: the events it covers and who hears. */
|
|
6
|
+
export const ChangeRule = z.object({
|
|
7
|
+
calendars: keys.default([]),
|
|
8
|
+
/** only events with one of these tags (any case); [] = every event in the calendars */
|
|
9
|
+
tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
|
|
10
|
+
/** sent to these topics instead of the event's own (`events` and its calendars) */
|
|
11
|
+
topics: z.array(z.string().min(1).max(200)).max(10).default([]),
|
|
12
|
+
});
|
|
13
|
+
export type ChangeRule = z.infer<typeof ChangeRule>;
|
|
14
|
+
export const MAX_CHANGE_RULES = 10;
|
|
15
|
+
// one rule, as saved before there could be several: a list of one, or none while it had no calendars
|
|
16
|
+
const oneRule = (v: unknown) => {
|
|
17
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return v;
|
|
18
|
+
const calendars = (v as { calendars?: unknown }).calendars;
|
|
19
|
+
return Array.isArray(calendars) && calendars.length ? [v] : [];
|
|
20
|
+
};
|
|
5
21
|
export const PushAuto = z.object({
|
|
6
22
|
/** collection keys whose first publish sends a notification */
|
|
7
23
|
newPost: z.object({ collections: keys.default([]) }).default({}),
|
|
@@ -11,14 +27,8 @@ export const PushAuto = z.object({
|
|
|
11
27
|
startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
|
|
12
28
|
/** "We're live": once per broadcast that live video detects, to devices on the `live` topic */
|
|
13
29
|
live: z.object({ enabled: z.boolean().default(false) }).default({}),
|
|
14
|
-
/** a published event's new date, time or place, or a cancellation, once its edits settle */
|
|
15
|
-
eventChanges: z.
|
|
16
|
-
calendars: keys.default([]),
|
|
17
|
-
/** only events with one of these tags (any case); [] = every event in the calendars */
|
|
18
|
-
tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
|
|
19
|
-
/** sent to these topics instead of the event's own (`events` and its calendars) */
|
|
20
|
-
topics: z.array(z.string().min(1).max(200)).max(10).default([]),
|
|
21
|
-
}).default({}),
|
|
30
|
+
/** a published event's new date, time or place, or a cancellation, once its edits settle: one notice when any rule covers it */
|
|
31
|
+
eventChanges: z.preprocess(oneRule, z.array(ChangeRule).max(MAX_CHANGE_RULES)).default([]),
|
|
22
32
|
});
|
|
23
33
|
export type PushAuto = z.infer<typeof PushAuto>;
|
|
24
34
|
export const PushSettings = z.object({ auto: PushAuto.default({}) });
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -1225,6 +1225,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1225
1225
|
write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')}
|
|
1226
1226
|
WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN ('pending','confirmed')) + ? <= ?)`,
|
|
1227
1227
|
args: [...ins.args!,cap,b.eventId,b.start,b.seats,cap] };
|
|
1228
|
+
// 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 ('pending','confirmed'))`,
|
|
1230
|
+
args: [...write.args!,b.eventId,b.start,b.email] };
|
|
1228
1231
|
} else if (holds && b.resourceId) {
|
|
1229
1232
|
write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE NOT EXISTS (
|
|
1230
1233
|
SELECT 1 FROM bookings WHERE resource_id=? AND kind IN ('booking','block') AND status IN ('pending','confirmed') AND hold_start < ? AND hold_end > ?)`,
|