@acms-subir/core 0.3.38 → 0.3.40
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 +18 -0
- package/docs/mobile.md +1 -1
- package/integration.mjs +1 -0
- 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 +3 -1
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +55 -1
- package/src/components/editor/event/registration.ts +18 -1
- package/src/lib/booking/emails.ts +37 -11
- package/src/lib/booking/service.ts +96 -19
- package/src/lib/calendar/api.ts +3 -2
- package/src/lib/calendar/events.ts +11 -0
- package/src/lib/calendar/members-config.ts +5 -0
- package/src/lib/calendar/requests.ts +4 -4
- package/src/lib/calendar/rsvp-words.ts +31 -2
- package/src/lib/content-service.ts +29 -14
- package/src/lib/drafts.ts +2 -0
- package/src/lib/editor/documents.ts +4 -1
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +66 -1
- package/src/lib/email/service.ts +1 -1
- package/src/lib/email/store.ts +17 -1
- package/src/lib/email/types.ts +3 -1
- package/src/lib/legal/text-en.ts +1 -1
- package/src/lib/legal/text-es.ts +1 -1
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/calendar.ts +23 -1
- package/src/lib/mobile/http.ts +1 -1
- package/src/lib/mobile/manage.ts +1 -1
- package/src/lib/mobile/openapi.ts +3 -2
- package/src/lib/mobile/rsvp.ts +14 -8
- package/src/lib/mobile/schemas.ts +7 -3
- 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 +7 -3
- package/src/lib/repo/types.ts +8 -4
- package/src/lib/schema.ts +8 -1
- package/src/pages/api/v1/events/[slug]/index.ts +8 -0
- 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
|
@@ -9,8 +9,12 @@ import { loadFeed, expandVEvents } from '../calendar/ics';
|
|
|
9
9
|
import { emitWebhook } from '../webhooks';
|
|
10
10
|
import { parseFrom } from '../email/config';
|
|
11
11
|
import { kickEmail } from '../email/service';
|
|
12
|
+
import type { RsvpStaffNotice } from '../email/types';
|
|
13
|
+
import { LEGAL } from '../legal/config';
|
|
14
|
+
import { isRole } from '../roles';
|
|
15
|
+
import { siteLocale } from '../dates';
|
|
12
16
|
import { eventUrl } from '../calendar/config';
|
|
13
|
-
import { eventTimeZone, locationLine, occurrenceKey } from '../calendar/events';
|
|
17
|
+
import { eventTimeZone, locationLine, occurrenceKey, rsvpDeadline } from '../calendar/events';
|
|
14
18
|
import { normalizePageLocale } from '../i18n';
|
|
15
19
|
import { localizeDoc } from '../translations';
|
|
16
20
|
import { bookingStrings } from '../email/booking-words';
|
|
@@ -18,6 +22,8 @@ import { bookingEmail, bookingLanguage, bookingManagePath, whenText, type Bookin
|
|
|
18
22
|
import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
|
|
19
23
|
import { decideStatus, linkApproval } from './approval';
|
|
20
24
|
import { eventHolds } from './holds';
|
|
25
|
+
import { MEMBERS, memberEventsPath } from '../calendar/members-config';
|
|
26
|
+
import { principalCan } from '../permissions/access';
|
|
21
27
|
|
|
22
28
|
export interface FlowOptions {
|
|
23
29
|
origin: string;
|
|
@@ -28,7 +34,8 @@ export interface FlowOptions {
|
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export class BookingError extends Error {
|
|
31
|
-
|
|
37
|
+
// code: why, when the status alone doesn't say (apps get it as the error code)
|
|
38
|
+
constructor(public status: number, message: string, public code?: 'duplicate') { super(message); }
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
const token = () => {
|
|
@@ -87,7 +94,7 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
|
|
|
87
94
|
const ev = raw && locale ? localizeDoc(raw, locale) : raw;
|
|
88
95
|
const tz = ev ? eventTimeZone(ev, settings) : settings.timezone || 'UTC';
|
|
89
96
|
return {
|
|
90
|
-
resource: null, link: null, event: ev,
|
|
97
|
+
resource: null, link: null, event: ev, tz,
|
|
91
98
|
title: ev?.title ?? 'Event',
|
|
92
99
|
location: ev ? locationLine(ev.location) : '',
|
|
93
100
|
when: whenText(b, tz, settings, ev?.allDay),
|
|
@@ -96,7 +103,7 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
|
|
|
96
103
|
const [resource, link] = await Promise.all([repo.getResource(b.resourceId), b.linkId ? repo.getBookingLink(b.linkId) : Promise.resolve(null)]);
|
|
97
104
|
const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
|
|
98
105
|
return {
|
|
99
|
-
resource, link, event: null as CalEvent | null,
|
|
106
|
+
resource, link, event: null as CalEvent | null, tz,
|
|
100
107
|
title: [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
|
|
101
108
|
location: resource?.name ?? '',
|
|
102
109
|
when: whenText(b, tz, settings, link?.mode === 'day'),
|
|
@@ -113,10 +120,17 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
|
|
|
113
120
|
};
|
|
114
121
|
}
|
|
115
122
|
|
|
116
|
-
|
|
123
|
+
// the booking in the admin; an RSVP among its event's (a cancelled one under Cancelled)
|
|
124
|
+
const adminBookingUrl = (origin: string, b: Booking, kind: BookingEmail) =>
|
|
125
|
+
`${origin}/admin/calendar/bookings?${b.eventId ? `event=${encodeURIComponent(b.eventId)}&` : ''}${kind === 'staff-rsvp-cancelled' ? 'tab=cancelled&' : ''}b=${b.id}`;
|
|
126
|
+
|
|
127
|
+
// snapshot.locale: the language to use instead of the booking's (a link sent
|
|
128
|
+
// again); taken/why/via: a staff RSVP email's job (rsvpStaffJobs)
|
|
129
|
+
export async function bookingMail(repo: ContentRepository, b: Booking, kind: BookingEmail, origin: string, from = '', snapshot?: { message?: string; eventTitle?: string; eventLocation?: string; eventAllDay?: number; locale?: string; taken?: number; why?: RsvpStaffNotice['why']; via?: Booking['source'] }, recipient = '') {
|
|
117
130
|
const site = await repo.getSettings();
|
|
131
|
+
const staff = kind.startsWith('staff-');
|
|
118
132
|
// the guest hears in the language they booked in; staff in the main one
|
|
119
|
-
const language = bookingLanguage(
|
|
133
|
+
const language = bookingLanguage(staff ? { locale: '' } : typeof snapshot?.locale === 'string' ? { locale: snapshot.locale } : b, site);
|
|
120
134
|
const settings = language.settings;
|
|
121
135
|
const d = await describe(repo,b,settings,language.locale);
|
|
122
136
|
if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
|
|
@@ -124,10 +138,42 @@ export async function bookingMail(repo: ContentRepository, b: Booking, kind: Boo
|
|
|
124
138
|
try { d.location = locationLine(JSON.parse(snapshot.eventLocation || '{}')); } catch { d.location = ''; }
|
|
125
139
|
d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
|
|
126
140
|
}
|
|
141
|
+
// "Fri Nov 7": the subject's date, in the event's zone
|
|
142
|
+
const counts = kind === 'staff-rsvp' || kind === 'staff-rsvp-cancelled' ? {
|
|
143
|
+
date: new Intl.DateTimeFormat(siteLocale(settings), { weekday: 'short', month: 'short', day: 'numeric', timeZone: d.tz }).format(b.start).replace(/,/g, ''),
|
|
144
|
+
taken: snapshot?.taken ?? await seatsTaken(repo, b.eventId, b.start), capacity: d.event?.rsvp.capacity ?? 0,
|
|
145
|
+
why: snapshot?.why ?? 'hosts', via: snapshot?.via ?? b.source,
|
|
146
|
+
} : undefined;
|
|
147
|
+
// a host without Manage bookings (a member who hosts) gets their My events
|
|
148
|
+
// page for the event instead of an admin page they can't open
|
|
149
|
+
const mine = counts && d.event && recipient ? await memberHostPath(repo, d.event, recipient) : '';
|
|
127
150
|
return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
|
|
128
|
-
manageUrl:
|
|
151
|
+
manageUrl:mine ? origin + mine : staff ? adminBookingUrl(origin, b, kind) : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
|
|
152
|
+
mineLink: !!mine,
|
|
129
153
|
siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
|
|
130
|
-
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang });
|
|
154
|
+
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang, rsvpStaff: counts });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The member's My events page for an event they host, when they can't manage bookings in the admin ('' otherwise). */
|
|
158
|
+
async function memberHostPath(repo: ContentRepository, e: CalEvent, email: string): Promise<string> {
|
|
159
|
+
const user = (await repo.listUsers()).find((u) => u.email && u.email.trim().toLowerCase() === email.trim().toLowerCase());
|
|
160
|
+
if (!user || !MEMBERS.enabled || !e.hostIds.includes(user.id)) return '';
|
|
161
|
+
return (await principalCan(repo, { user }, 'schedule.bookings')) ? '' : `${memberEventsPath()}/${encodeURIComponent(e.id)}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Who hears about an event's RSVPs (rsvp.notifyStaff): its hosts, else the
|
|
166
|
+
* contact email in Settings → Legal (then acms({ legal }), as legalOperator
|
|
167
|
+
* reads it), else the owners. Accounts that were removed, or whose role the
|
|
168
|
+
* site no longer has, are skipped. via: where the person did it.
|
|
169
|
+
*/
|
|
170
|
+
export async function rsvpStaff(repo: ContentRepository, e: CalEvent, settings: Settings, via: Booking['source']): Promise<RsvpStaffNotice | undefined> {
|
|
171
|
+
if (!e.rsvp.notifyStaff) return undefined;
|
|
172
|
+
const contact = settings.legal.contactEmail || LEGAL.contactEmail;
|
|
173
|
+
const users = e.hostIds.length || !contact ? (await repo.listUsers()).filter((u) => u.email && isRole(u.role)) : [];
|
|
174
|
+
const hosts = users.filter((u) => e.hostIds.includes(u.id)).map((u) => u.email);
|
|
175
|
+
const to = hosts.length ? hosts : contact ? [contact] : users.filter((u) => u.role === 'owner').map((u) => u.email);
|
|
176
|
+
return { to: [...new Set(to.map((a) => a.trim().toLowerCase()))].filter(Boolean), why: hosts.length ? 'hosts' : 'contact', via };
|
|
131
177
|
}
|
|
132
178
|
async function notify(repo: ContentRepository, _b: Booking, _kinds: BookingEmail[], opts: FlowOptions, _message?: string) {
|
|
133
179
|
// Repository mutations already committed their outbox jobs atomically.
|
|
@@ -203,12 +249,15 @@ export function guestChanges(b: Booking, link: BookingLink | null, now = Date.no
|
|
|
203
249
|
};
|
|
204
250
|
}
|
|
205
251
|
|
|
206
|
-
|
|
252
|
+
// via: where they cancelled (the manage page, or an app)
|
|
253
|
+
export async function guestCancel(repo: ContentRepository, b: Booking, opts: FlowOptions, via: Booking['source'] = 'web'): Promise<Booking> {
|
|
207
254
|
const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
|
|
208
255
|
if (b.status === 'cancelled') return b;
|
|
209
256
|
const can = guestChanges(b, link, opts.now);
|
|
210
257
|
if (!can.cancel) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to cancel.` : 'This booking can’t be cancelled.');
|
|
211
|
-
|
|
258
|
+
// the person cancelled an RSVP: its hosts hear about it (staff cancelling never emails staff)
|
|
259
|
+
const event = b.kind === 'rsvp' && b.eventId ? await repo.getEvent(b.eventId) : null;
|
|
260
|
+
return setBookingStatus(repo, b, 'cancelled', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
|
|
212
261
|
}
|
|
213
262
|
|
|
214
263
|
/** the link as a guest moving a booking sees it: no new time inside the change cutoff */
|
|
@@ -275,29 +324,56 @@ export async function createRsvp(
|
|
|
275
324
|
opts: FlowOptions
|
|
276
325
|
): Promise<Booking> {
|
|
277
326
|
const e = args.event;
|
|
327
|
+
const now = opts.now ?? Date.now();
|
|
278
328
|
if (!e.rsvp.enabled) throw new BookingError(400, 'This event doesn’t take RSVPs.');
|
|
279
329
|
if (e.cancelled) throw new BookingError(410, 'This event is cancelled.');
|
|
280
|
-
if (args.end <=
|
|
281
|
-
|
|
330
|
+
if (args.end <= now) throw new BookingError(410, 'This event has already happened.');
|
|
331
|
+
const settings = await repo.getSettings();
|
|
332
|
+
if (e.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, args.start, settings))) {
|
|
282
333
|
throw new BookingError(410, 'This date is cancelled.');
|
|
283
334
|
}
|
|
335
|
+
const deadline = rsvpDeadline({ event: e, start: args.start, end: args.end }, settings);
|
|
336
|
+
if (deadline !== null && deadline <= now) throw new BookingError(410, 'RSVPs for this date are closed.');
|
|
284
337
|
const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
|
|
285
338
|
const booker = validateBooker(null, args.booker);
|
|
286
|
-
await checkEmailCap(repo, booker.email, args.source,
|
|
339
|
+
await checkEmailCap(repo, booker.email, args.source, settings, opts.now);
|
|
287
340
|
const formResponse = e.rsvp.form ? await collectResponse(e.rsvp.form, args.booker.answers ?? {}, args.booker.formVersion) : undefined;
|
|
288
341
|
if (formResponse) booker.answers = displayAnswers(formResponse);
|
|
342
|
+
// one RSVP per address and date on public channels; staff may add more
|
|
343
|
+
const once = args.source !== 'admin';
|
|
344
|
+
const source = args.source ?? 'web';
|
|
345
|
+
// the hosts' email is queued with the RSVP: a duplicate or a replay inserts nothing, so it emails no one
|
|
289
346
|
const b = await repo.createBooking({
|
|
290
347
|
kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
|
|
291
348
|
start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
|
|
292
|
-
formResponse, notes: '', manageToken: token(), source
|
|
293
|
-
}, { capacity: e.rsvp.capacity, idempotency: opts.idempotency });
|
|
294
|
-
if (!b)
|
|
295
|
-
|
|
349
|
+
formResponse, notes: '', manageToken: token(), source, locale: normalizePageLocale(args.booker.locale ?? ''),
|
|
350
|
+
}, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency, staff: await rsvpStaff(repo, e, settings, source) });
|
|
351
|
+
if (!b) {
|
|
352
|
+
// a retry of a request that just committed: the caller replays that one
|
|
353
|
+
const retry = opts.idempotency ? await repo.getBookingRequest(opts.idempotency.keyHash) : null;
|
|
354
|
+
const mine = once && !retry ? (await repo.listBookings({ eventId: e.id, email: booker.email, from: args.start - 1, to: args.start + 1, statuses: ['pending', 'confirmed'], kinds: ['rsvp'] })).find((x) => x.start === args.start) : undefined;
|
|
355
|
+
if (mine) {
|
|
356
|
+
await resendManageLink(repo, mine, args.booker.locale === undefined ? mine.locale : normalizePageLocale(args.booker.locale), opts);
|
|
357
|
+
// anyone can type an address: say only that it has one
|
|
358
|
+
throw new BookingError(409, 'You already have an RSVP for this date. We sent you an email with a link to change it.', 'duplicate');
|
|
359
|
+
}
|
|
360
|
+
throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
|
|
361
|
+
}
|
|
362
|
+
await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received', 'staff-rsvp'], opts);
|
|
296
363
|
await hook(repo, 'booking.created', b, opts);
|
|
297
364
|
if (b.status === 'confirmed') await hook(repo, 'booking.confirmed', b, opts);
|
|
298
365
|
return b;
|
|
299
366
|
}
|
|
300
367
|
|
|
368
|
+
// The address gets its RSVP's own link again, in the language it just used,
|
|
369
|
+
// at most once per 10 minutes per RSVP so the form can't flood an inbox.
|
|
370
|
+
async function resendManageLink(repo: ContentRepository, b: Booking, locale: string, opts: FlowOptions) {
|
|
371
|
+
if (!await repo.email.rateLimit(`rsvp-link:${b.id}`, 1, 10 * 60_000)) return;
|
|
372
|
+
await repo.email.enqueue({dedupe:`${b.id}:${b.notificationVersion}:link:${Date.now()}`,kind:'booking',recipient:b.email,
|
|
373
|
+
payload:JSON.stringify({bookingId:b.id,version:b.notificationVersion,kind:'link',locale}),configVersion:(await repo.email.config())?.version||0,expiresAt:b.end});
|
|
374
|
+
await kickEmail(repo,opts.locals,opts.waitUntil);
|
|
375
|
+
}
|
|
376
|
+
|
|
301
377
|
const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
|
|
302
378
|
pending: ['confirmed', 'declined', 'cancelled'],
|
|
303
379
|
confirmed: ['cancelled'],
|
|
@@ -305,7 +381,8 @@ const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
|
|
|
305
381
|
cancelled: ['confirmed'],
|
|
306
382
|
};
|
|
307
383
|
|
|
308
|
-
|
|
384
|
+
// staff: who to tell that the person cancelled (guestCancel); staff changes tell no one
|
|
385
|
+
export async function setBookingStatus(repo: ContentRepository, b: Booking, status: BookingStatus, opts: FlowOptions, message?: string, staff?: RsvpStaffNotice): Promise<Booking> {
|
|
309
386
|
if (b.status === status) return b;
|
|
310
387
|
if (!TRANSITIONS[b.status].includes(status)) throw new BookingError(409, `A ${b.status} booking can’t become ${status}.`);
|
|
311
388
|
// re-confirming a declined/cancelled booking must re-check the slot
|
|
@@ -314,7 +391,7 @@ export async function setBookingStatus(repo: ContentRepository, b: Booking, stat
|
|
|
314
391
|
.some((x) => x.id !== b.id && x.kind !== 'rsvp' && x.holdStart < b.holdEnd && x.holdEnd > b.holdStart);
|
|
315
392
|
if (clash) throw new BookingError(409, 'That time has been booked since — it can’t be restored.');
|
|
316
393
|
}
|
|
317
|
-
await repo.updateBooking(b.id, { status, notificationMessage: message });
|
|
394
|
+
await repo.updateBooking(b.id, { status, notificationMessage: message, staff });
|
|
318
395
|
const next = (await repo.getBooking(b.id))!;
|
|
319
396
|
if (!next || next.status !== status) throw new BookingError(409, 'This booking changed. Refresh and try again.');
|
|
320
397
|
if (b.kind !== 'block') {
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -27,7 +27,8 @@ export interface EventJson {
|
|
|
27
27
|
cancelledDates: string[];
|
|
28
28
|
location: CalEvent['location'];
|
|
29
29
|
cta: CalEvent['cta'];
|
|
30
|
-
|
|
30
|
+
// notifyStaff (whether the hosts get RSVP emails) only for staff callers
|
|
31
|
+
rsvp?: Omit<CalEvent['rsvp'], 'notifyStaff'> & Partial<Pick<CalEvent['rsvp'], 'notifyStaff'>>;
|
|
31
32
|
image: string;
|
|
32
33
|
imageAlt: string;
|
|
33
34
|
tags: string[];
|
|
@@ -59,7 +60,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
59
60
|
cancelledDates: e.cancelledDates ?? [],
|
|
60
61
|
location: e.location,
|
|
61
62
|
cta: e.cta,
|
|
62
|
-
rsvp: e.rsvp,
|
|
63
|
+
rsvp: opts.includePrivate ? e.rsvp : (({ notifyStaff: _, ...rsvp }) => rsvp)(e.rsvp),
|
|
63
64
|
image: e.cover ? abs(origin, e.cover) : '',
|
|
64
65
|
imageAlt: e.coverAlt,
|
|
65
66
|
tags: e.tags.filter((t) => !t.startsWith('#')),
|
|
@@ -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 {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
|
|
3
|
+
/** where the built-in member pages live (acms({ members })); `notify` hears about new requests */
|
|
4
|
+
export const MEMBERS: { enabled: boolean; base: string; notify: string[] } = { enabled: false, base: '/members', notify: [], ...(userConfig.members ?? {}) };
|
|
5
|
+
export const memberEventsPath = () => `${MEMBERS.base}/events`;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { userConfig } from 'virtual:acms-config';
|
|
2
1
|
import type { ContentRepository } from '../repo/types';
|
|
3
2
|
import type { CalEvent, EventRequest } from '../schema';
|
|
4
3
|
import { EventLocation } from '../schema';
|
|
@@ -20,9 +19,10 @@ import { kickEmail } from '../email/service';
|
|
|
20
19
|
// Everything here checks who is acting, so the built-in member pages and a
|
|
21
20
|
// site's own pages (through @acms-subir/core/server) share the same rules.
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export
|
|
22
|
+
// acms({ members }) and the My events path live on their own, so booking
|
|
23
|
+
// emails (which this code depends on) can read them without an import cycle
|
|
24
|
+
export { MEMBERS, memberEventsPath } from './members-config';
|
|
25
|
+
import { MEMBERS, memberEventsPath } from './members-config';
|
|
26
26
|
|
|
27
27
|
/** who is acting: currentUser() from @acms-subir/core/server fits */
|
|
28
28
|
export interface Actor { id: string; can(permission: string): boolean }
|
|
@@ -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 and notifyStaff (older clients): the current ones stay
|
|
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' | 'notifyStaff'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff'>>;
|
|
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,31 @@ 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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
|
|
137
|
+
if (rsvp) {
|
|
138
|
+
// 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.
|
|
141
|
+
const previous = mergeEventDraft(before).rsvp;
|
|
142
|
+
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
|
+
if (!Object.hasOwn(rsvp, 'form') && previous.form) next.form = previous.form;
|
|
145
|
+
if (next.form !== undefined) {
|
|
146
|
+
const form = FormDefinition.safeParse(next.form);
|
|
147
|
+
if (!form.success) throw new ApiError(400, form.error.issues[0]?.message || 'Invalid registration form.');
|
|
148
|
+
next.form = form.data;
|
|
149
|
+
}
|
|
150
|
+
if (next.closesAt !== null) {
|
|
151
|
+
const w = parseWall(next.closesAt);
|
|
152
|
+
if (!w || w.dateOnly) throw new ApiError(400, 'The RSVP deadline must be a date and time (YYYY-MM-DDTHH:mm).');
|
|
153
|
+
next.closesAt = formatWall(w);
|
|
154
|
+
}
|
|
155
|
+
if (!Number.isInteger(next.closeBefore) || next.closeBefore < 0 || next.closeBefore > 60 * 1440) {
|
|
156
|
+
throw new ApiError(400, 'The RSVP deadline can be at most 60 days before each date (closeBefore: 0–86400 minutes).');
|
|
157
|
+
}
|
|
158
|
+
if (typeof next.notifyStaff !== 'boolean') throw new ApiError(400, 'rsvp.notifyStaff must be true or false.');
|
|
159
|
+
out.rsvp = next;
|
|
145
160
|
}
|
|
146
161
|
if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
|
|
147
162
|
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; 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
57
|
return out;
|
|
56
58
|
}
|
|
57
59
|
export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
|
|
@@ -160,7 +160,10 @@ 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;
|
|
164
|
+
// notifyStaff: the hosts get an email about each new or cancelled RSVP. Left out = kept
|
|
165
|
+
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(),
|
|
164
167
|
}).partial().strict();
|
|
165
168
|
export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
|
|
166
169
|
|
|
@@ -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, notifyStaff: bool, form: FormDefinition.optional() }),
|
|
51
51
|
}).nullable(),
|
|
52
52
|
languages: z.array(str),
|
|
53
53
|
});
|