@acms-subir/core 0.3.39 → 0.3.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/mobile.md +2 -1
- package/integration.mjs +3 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/rsvp/recipe.json +2 -2
- package/package.json +1 -1
- package/src/actions/index.ts +1 -1
- package/src/components/EventPage.astro +16 -8
- package/src/components/editor/EventPanel.tsx +3 -2
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +9 -0
- package/src/components/editor/event/registration.ts +1 -1
- package/src/lib/booking/agent.ts +1 -1
- package/src/lib/booking/emails.ts +54 -15
- package/src/lib/booking/service.ts +207 -23
- package/src/lib/calendar/api.ts +3 -2
- package/src/lib/calendar/members-config.ts +5 -0
- package/src/lib/calendar/requests.ts +4 -4
- package/src/lib/calendar/rsvp-words.ts +69 -8
- package/src/lib/content-service.ts +13 -5
- package/src/lib/drafts.ts +2 -2
- package/src/lib/editor/bookings.ts +5 -3
- package/src/lib/editor/documents.ts +4 -2
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +82 -2
- 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/forms/events.ts +4 -2
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/text-en.ts +1 -1
- package/src/lib/legal/text-es.ts +1 -1
- package/src/lib/mobile/bookings.ts +4 -3
- package/src/lib/mobile/calendar.ts +23 -1
- package/src/lib/mobile/manage.ts +18 -4
- package/src/lib/mobile/openapi.ts +6 -3
- package/src/lib/mobile/rsvp.ts +10 -5
- package/src/lib/mobile/schemas.ts +12 -4
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +54 -13
- package/src/lib/repo/types.ts +15 -5
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +13 -2
- package/src/pages/admin/calendar/bookings.astro +57 -13
- package/src/pages/api/calendar/bookings/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -2
- package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
- package/src/pages/api/v1/events/[slug]/index.ts +8 -0
- package/src/pages/book/manage/[token].astro +57 -15
- package/src/pages/events/[slug].astro +3 -2
- package/src/styles/admin.css +3 -1
- package/src/styles/site.css +10 -1
- package/src/theme-sdk/index.ts +1 -1
|
@@ -20,8 +20,9 @@ export interface EventMeta {
|
|
|
20
20
|
cta: EventCta;
|
|
21
21
|
calendarIds: string[];
|
|
22
22
|
fields: Record<string, string>;
|
|
23
|
-
// closesAt: wall time like start; closeBefore: minutes before each date (0 = until it ends)
|
|
24
|
-
|
|
23
|
+
// closesAt: wall time like start; closeBefore: minutes before each date (0 = until it ends);
|
|
24
|
+
// notifyStaff: the hosts get an email about each new or cancelled RSVP; waitlist: a full date takes places in line
|
|
25
|
+
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; waitlist?: boolean; form?: FormDefinition };
|
|
25
26
|
roomHold: RoomHoldValue;
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -169,7 +169,7 @@ const NO_MAPS: MapsStatus = { enabled: false, provider: '', label: '', search: f
|
|
|
169
169
|
const NO_EVENT: EventMeta = {
|
|
170
170
|
start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
|
|
171
171
|
location: NO_LOCATION, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
|
|
172
|
-
rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0 },
|
|
172
|
+
rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true, waitlist: false },
|
|
173
173
|
roomHold: NO_HOLD,
|
|
174
174
|
};
|
|
175
175
|
|
|
@@ -73,6 +73,15 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
|
|
|
73
73
|
<span><b>Confirm automatically</b><small>Off: RSVPs wait for your approval in Bookings</small></span>
|
|
74
74
|
<input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.autoConfirm} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, autoConfirm: e.target.checked } })} />
|
|
75
75
|
</label>
|
|
76
|
+
<label className="pe-switch-row">
|
|
77
|
+
<span><b>Email the hosts</b><small>About each new RSVP and cancellation. Without hosts, the contact email in Settings → Legal gets them</small></span>
|
|
78
|
+
<input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.notifyStaff !== false} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, notifyStaff: e.target.checked } })} />
|
|
79
|
+
</label>
|
|
80
|
+
{/* a waitlist needs a limit to be full */}
|
|
81
|
+
<label className="pe-switch-row">
|
|
82
|
+
<span><b>Offer a waitlist when it’s full</b><small>{meta.rsvp.capacity ? 'People join a line. When a seat opens, the next person is emailed and it’s held for them for 24 hours' : 'Set a number of seats to use it'}</small></span>
|
|
83
|
+
<input type="checkbox" role="switch" className="pe-switch" disabled={!meta.rsvp.capacity} checked={Boolean(meta.rsvp.waitlist && meta.rsvp.capacity)} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, waitlist: e.target.checked } })} />
|
|
84
|
+
</label>
|
|
76
85
|
</div>
|
|
77
86
|
<Deadline meta={meta} onChange={onChange} />
|
|
78
87
|
<div className="pe-evr-form">
|
|
@@ -9,7 +9,7 @@ export type RegistrationMode = 'none' | 'link' | 'rsvp';
|
|
|
9
9
|
|
|
10
10
|
interface RegistrationData {
|
|
11
11
|
cta: EventCta;
|
|
12
|
-
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number };
|
|
12
|
+
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; waitlist?: boolean };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function registrationMode({ cta, rsvp }: RegistrationData): RegistrationMode {
|
package/src/lib/booking/agent.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const BookingToolInputs = [
|
|
|
18
18
|
minutes: z.number().int().optional(),
|
|
19
19
|
}).strict() }).strict(),
|
|
20
20
|
z.object({ tool: z.literal('list_bookings'), arguments: z.object({
|
|
21
|
-
status: z.enum(['pending', 'confirmed', 'declined', 'cancelled']).optional(), resource: z.string().optional(),
|
|
21
|
+
status: z.enum(['pending', 'confirmed', 'declined', 'cancelled', 'waitlisted', 'offered', 'expired']).optional(), resource: z.string().optional(),
|
|
22
22
|
from: z.string().optional(), limit: z.number().int().min(1).max(100).default(25),
|
|
23
23
|
}).strict() }).strict(),
|
|
24
24
|
z.object({ tool: z.literal('create_booking'), arguments: z.object({
|
|
@@ -13,6 +13,7 @@ import { isExtraLocale } from '../translations';
|
|
|
13
13
|
import { fill } from '../live/words';
|
|
14
14
|
import { bookingStrings, mailCopy, type BookingStrings } from '../email/booking-words';
|
|
15
15
|
import type { Mail } from '../email';
|
|
16
|
+
import type { RsvpStaffNotice } from '../email/types';
|
|
16
17
|
|
|
17
18
|
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
18
19
|
|
|
@@ -43,6 +44,11 @@ export function whenText(b: Pick<Booking, 'start' | 'end'>, tz: string, settings
|
|
|
43
44
|
return `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} · ${f({ hour: 'numeric', minute: '2-digit' }).format(b.start)} – ${f({ hour: 'numeric', minute: '2-digit' }).format(b.end)} ${zone}`.trim();
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/** until when a freed spot is held, in the booking's language and the event's zone: "Fri, Nov 7, 6:00 PM UTC" */
|
|
48
|
+
export function untilText(at: number, tz: string, settings: Settings): string {
|
|
49
|
+
return new Intl.DateTimeFormat(siteLocale(settings), { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short', timeZone: tz }).format(at);
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
const utc = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
47
53
|
|
|
48
54
|
// lang: the description's language ("Manage: <url>")
|
|
@@ -75,30 +81,41 @@ interface Ctx {
|
|
|
75
81
|
allDay?: boolean;
|
|
76
82
|
/** the words' language: the booking's for guest emails, the main one for staff */
|
|
77
83
|
lang?: string;
|
|
84
|
+
/** staff RSVP emails: the date's short form, seats taken right after, why they get it and where the person did it */
|
|
85
|
+
rsvpStaff?: { date: string; taken: number; capacity: number; why: RsvpStaffNotice['why']; via: Booking['source'] };
|
|
86
|
+
/** manageUrl is the recipient's My events page (a host without Manage bookings) */
|
|
87
|
+
mineLink?: boolean;
|
|
88
|
+
/** waitlist emails: the place in line (null once it isn't in line), how many hours a freed spot is held, and until when this one is */
|
|
89
|
+
waitlist?: { position: number | null; hours: number; until: string };
|
|
78
90
|
}
|
|
79
91
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
// own: a staff email's own rows and footer instead of the details and reference
|
|
93
|
+
function layout(heading: string, intro: string, c: Ctx, w: BookingStrings, button?: { href: string; label: string }, own?: { rows: [string, string][]; footer: string }): { html: string; text: string } {
|
|
94
|
+
const rows: [string, string][] = own?.rows ?? [[w.what, c.title], [w.when, c.when], ...(c.location ? [[w.where, c.location] as [string, string]] : [])];
|
|
95
|
+
if (!own && c.booking.seats > 1) rows.push([w.seats, String(c.booking.seats)]);
|
|
96
|
+
const footer = own?.footer ?? fill(w.reference, { ref: c.booking.id.slice(0, 8).toUpperCase() });
|
|
84
97
|
const html = `<!doctype html><html lang="${esc(c.lang || 'en')}"><body style="margin:0;background:#f6f5f2;font-family:-apple-system,Segoe UI,Inter,Arial,sans-serif;color:#1e1f24">
|
|
85
98
|
<div style="max-width:520px;margin:0 auto;padding:28px 20px">
|
|
86
99
|
<p style="font-size:13px;color:#6b6e78;margin:0 0 18px">${esc(c.siteTitle)}</p>
|
|
87
|
-
<h1 style="font-size:22px;margin:0 0
|
|
88
|
-
|
|
100
|
+
<h1 style="font-size:22px;margin:0 0 ${intro ? 8 : 18}px">${esc(heading)}</h1>
|
|
101
|
+
${intro ? `<p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>` : ''}
|
|
89
102
|
<table style="width:100%;border-collapse:collapse;background:#fff;border:1px solid #e5e3de;border-radius:10px">${rows
|
|
90
|
-
.map(([k, v]) => `<tr><td style="padding:10px 14px;font-size:13px;color:#6b6e78;width:70px;border-bottom:1px solid #eeece8">${esc(k)}</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #eeece8">${esc(v)}</td></tr>`)
|
|
103
|
+
.map(([k, v]) => `<tr><td style="padding:10px 14px;font-size:13px;color:#6b6e78;width:70px;border-bottom:1px solid #eeece8">${esc(k)}</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #eeece8">${esc(v).replace(/\n/g, '<br>')}</td></tr>`)
|
|
91
104
|
.join('')}</table>
|
|
92
105
|
${c.message ? `<p style="font-size:14px;line-height:1.55;margin:18px 0 0">${esc(c.message)}</p>` : ''}
|
|
93
106
|
${button ? `<p style="margin:22px 0 0"><a href="${esc(button.href)}" style="display:inline-block;background:#1e1f24;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:10px 16px;border-radius:8px">${esc(button.label)}</a></p>` : ''}
|
|
94
|
-
<p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(
|
|
107
|
+
<p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(footer)}</p>
|
|
95
108
|
</div></body></html>`;
|
|
96
|
-
const text = [heading, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '',
|
|
109
|
+
const text = [heading, '', ...(intro ? [intro, ''] : []), ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '', footer].join('\n');
|
|
97
110
|
return { html, text };
|
|
98
111
|
}
|
|
99
112
|
|
|
100
|
-
// link: the booking's own link again, after someone tried to make it twice
|
|
101
|
-
|
|
113
|
+
// link: the booking's own link again, after someone tried to make it twice.
|
|
114
|
+
// waitlisted / offered / left: they joined a full date's waitlist, a spot
|
|
115
|
+
// opened and is held for them, or they're off the waitlist (left it, released
|
|
116
|
+
// the spot or staff removed them). staff-rsvp(-cancelled): the hosts hear
|
|
117
|
+
// about a new RSVP, or one the person cancelled
|
|
118
|
+
export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'link' | 'waitlisted' | 'offered' | 'left' | 'staff-new' | 'staff-rsvp' | 'staff-rsvp-cancelled';
|
|
102
119
|
|
|
103
120
|
export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
104
121
|
const w = bookingStrings(c.lang);
|
|
@@ -106,10 +123,11 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
106
123
|
const manage = { href: c.manageUrl, label: rsvp ? w.rsvp.manage : w.manage };
|
|
107
124
|
const ics = (method: 'REQUEST' | 'CANCEL' = 'REQUEST') => [{ filename: 'booking.ics', type: `text/calendar; charset=utf-8; method=${method === 'CANCEL' ? 'CANCEL' : c.organizer ? 'REQUEST' : 'PUBLISH'}`, content: bookingIcs(c.booking, c.title, c.location, c.manageUrl, c.host, method, { organizer: c.organizer, allDay: c.allDay, lang: c.lang }) }];
|
|
108
125
|
// null: no button (nothing left to change)
|
|
109
|
-
const guest = (k: Exclude<BookingEmail,
|
|
126
|
+
const guest = (k: Exclude<BookingEmail, `staff-${string}`>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
|
|
110
127
|
const copy = mailCopy(w, k, rsvp);
|
|
111
|
-
const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when
|
|
112
|
-
|
|
128
|
+
const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when,
|
|
129
|
+
position: String(c.waitlist?.position ?? ''), hours: String(c.waitlist?.hours ?? ''), until: c.waitlist?.until ?? '' };
|
|
130
|
+
return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(fill(copy.heading, vars), fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
|
|
113
131
|
};
|
|
114
132
|
switch (kind) {
|
|
115
133
|
case 'received': return guest('received');
|
|
@@ -119,7 +137,11 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
119
137
|
case 'updated': return { ...guest('updated'), attachments: ics() };
|
|
120
138
|
case 'rescheduled': return { ...guest('rescheduled'), attachments: ics() };
|
|
121
139
|
case 'reminder': return guest('reminder');
|
|
122
|
-
|
|
140
|
+
// asked again while in line or holding a spot: that email again
|
|
141
|
+
case 'link': return c.booking.status === 'waitlisted' || c.booking.status === 'offered' ? bookingEmail(c.booking.status, c) : guest('link');
|
|
142
|
+
case 'waitlisted': return guest('waitlisted', {}, { href: c.manageUrl, label: w.waitlist.manage });
|
|
143
|
+
case 'offered': return guest('offered', {}, { href: c.manageUrl, label: w.waitlist.confirm });
|
|
144
|
+
case 'left': return guest('left', {}, null);
|
|
123
145
|
case 'staff-new': {
|
|
124
146
|
const pending = c.booking.status === 'pending';
|
|
125
147
|
const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
|
|
@@ -128,5 +150,22 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
128
150
|
const m = layout(pending ? w.staff.pending : w.staff.booked, `${who}${answers ? `\n${answers}` : ''}`, c, w, { href: c.manageUrl, label: pending ? w.staff.review : w.staff.open });
|
|
129
151
|
return { to: [], subject: fill(pending ? w.staff.pendingSubject : w.staff.bookedSubject, vars), ...m, replyTo: c.booking.email || undefined };
|
|
130
152
|
}
|
|
153
|
+
case 'staff-rsvp':
|
|
154
|
+
case 'staff-rsvp-cancelled': {
|
|
155
|
+
const r = w.rsvpStaff, s = c.rsvpStaff!, b = c.booking;
|
|
156
|
+
const cancelled = kind === 'staff-rsvp-cancelled', pending = !cancelled && b.status === 'pending';
|
|
157
|
+
const seats = String(b.seats), count = String(s.taken);
|
|
158
|
+
const answers = Object.entries(b.answers).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
159
|
+
const rows: [string, string][] = [
|
|
160
|
+
[r.name, b.name], [r.email, b.email], ...(b.phone ? [[r.phone, b.phone] as [string, string]] : []), [w.when, c.when],
|
|
161
|
+
[w.seats, fill(cancelled ? (b.seats === 1 ? r.seatFreed : r.seatsFreed) : pending ? r.pending : r.confirmed, { seats })],
|
|
162
|
+
...(answers ? [[r.answers, answers] as [string, string]] : []),
|
|
163
|
+
[r.now, s.capacity ? fill(r.seatsTakenOf, { count, capacity: String(s.capacity) }) : fill(s.taken === 1 ? r.seatTaken : r.seatsTaken, { count })],
|
|
164
|
+
];
|
|
165
|
+
const vars = { title: c.title, date: s.date };
|
|
166
|
+
const footer = `${r.via[s.via === 'web' || s.via === 'app' ? s.via : 'staff']} · ${s.why === 'hosts' ? r.hosts : r.noHosts}`;
|
|
167
|
+
const m = layout(fill(cancelled ? r.cancelledHeading : r.heading, vars), '', c, w, { href: c.manageUrl, label: c.mineLink ? r.openMine : pending ? w.staff.review : w.staff.open }, { rows, footer });
|
|
168
|
+
return { to: [], subject: fill(cancelled ? r.cancelledSubject : r.subject, vars), ...m, replyTo: b.email || undefined };
|
|
169
|
+
}
|
|
131
170
|
}
|
|
132
171
|
}
|
|
@@ -9,15 +9,21 @@ 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
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';
|
|
17
|
-
import { bookingEmail, bookingLanguage, bookingManagePath, whenText, type BookingEmail } from './emails';
|
|
21
|
+
import { bookingEmail, bookingLanguage, bookingManagePath, untilText, whenText, type BookingEmail } from './emails';
|
|
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;
|
|
@@ -37,6 +43,9 @@ const token = () => {
|
|
|
37
43
|
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
38
44
|
};
|
|
39
45
|
|
|
46
|
+
/** how long a spot freed for the next person on a waitlist is held for them (ms) */
|
|
47
|
+
export const WAITLIST_HOLD = 24 * 3_600_000;
|
|
48
|
+
|
|
40
49
|
// ---- availability context ------------------------------------------------
|
|
41
50
|
|
|
42
51
|
const busyCache = new Map<string, { at: number; intervals: Interval[] }>();
|
|
@@ -88,7 +97,7 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
|
|
|
88
97
|
const ev = raw && locale ? localizeDoc(raw, locale) : raw;
|
|
89
98
|
const tz = ev ? eventTimeZone(ev, settings) : settings.timezone || 'UTC';
|
|
90
99
|
return {
|
|
91
|
-
resource: null, link: null, event: ev,
|
|
100
|
+
resource: null, link: null, event: ev, tz,
|
|
92
101
|
title: ev?.title ?? 'Event',
|
|
93
102
|
location: ev ? locationLine(ev.location) : '',
|
|
94
103
|
when: whenText(b, tz, settings, ev?.allDay),
|
|
@@ -97,7 +106,7 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
|
|
|
97
106
|
const [resource, link] = await Promise.all([repo.getResource(b.resourceId), b.linkId ? repo.getBookingLink(b.linkId) : Promise.resolve(null)]);
|
|
98
107
|
const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
|
|
99
108
|
return {
|
|
100
|
-
resource, link, event: null as CalEvent | null,
|
|
109
|
+
resource, link, event: null as CalEvent | null, tz,
|
|
101
110
|
title: [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
|
|
102
111
|
location: resource?.name ?? '',
|
|
103
112
|
when: whenText(b, tz, settings, link?.mode === 'day'),
|
|
@@ -114,11 +123,17 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
|
|
|
114
123
|
};
|
|
115
124
|
}
|
|
116
125
|
|
|
117
|
-
//
|
|
118
|
-
|
|
126
|
+
// the booking in the admin; an RSVP among its event's (a cancelled one under Cancelled)
|
|
127
|
+
const adminBookingUrl = (origin: string, b: Booking, kind: BookingEmail) =>
|
|
128
|
+
`${origin}/admin/calendar/bookings?${b.eventId ? `event=${encodeURIComponent(b.eventId)}&` : ''}${kind === 'staff-rsvp-cancelled' ? 'tab=cancelled&' : ''}b=${b.id}`;
|
|
129
|
+
|
|
130
|
+
// snapshot.locale: the language to use instead of the booking's (a link sent
|
|
131
|
+
// again); taken/why/via: a staff RSVP email's job (rsvpStaffJobs)
|
|
132
|
+
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 = '') {
|
|
119
133
|
const site = await repo.getSettings();
|
|
134
|
+
const staff = kind.startsWith('staff-');
|
|
120
135
|
// the guest hears in the language they booked in; staff in the main one
|
|
121
|
-
const language = bookingLanguage(
|
|
136
|
+
const language = bookingLanguage(staff ? { locale: '' } : typeof snapshot?.locale === 'string' ? { locale: snapshot.locale } : b, site);
|
|
122
137
|
const settings = language.settings;
|
|
123
138
|
const d = await describe(repo,b,settings,language.locale);
|
|
124
139
|
if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
|
|
@@ -126,10 +141,47 @@ export async function bookingMail(repo: ContentRepository, b: Booking, kind: Boo
|
|
|
126
141
|
try { d.location = locationLine(JSON.parse(snapshot.eventLocation || '{}')); } catch { d.location = ''; }
|
|
127
142
|
d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
|
|
128
143
|
}
|
|
144
|
+
// "Fri Nov 7": the subject's date, in the event's zone
|
|
145
|
+
const counts = kind === 'staff-rsvp' || kind === 'staff-rsvp-cancelled' ? {
|
|
146
|
+
date: new Intl.DateTimeFormat(siteLocale(settings), { weekday: 'short', month: 'short', day: 'numeric', timeZone: d.tz }).format(b.start).replace(/,/g, ''),
|
|
147
|
+
taken: snapshot?.taken ?? await seatsTaken(repo, b.eventId, b.start), capacity: d.event?.rsvp.capacity ?? 0,
|
|
148
|
+
why: snapshot?.why ?? 'hosts', via: snapshot?.via ?? b.source,
|
|
149
|
+
} : undefined;
|
|
150
|
+
// a host without Manage bookings (a member who hosts) gets their My events
|
|
151
|
+
// page for the event instead of an admin page they can't open
|
|
152
|
+
const mine = counts && d.event && recipient ? await memberHostPath(repo, d.event, recipient) : '';
|
|
153
|
+
// a place in line (its number when sent) or a held spot (until when, in the event's zone)
|
|
154
|
+
const waitlist = b.status === 'waitlisted' || b.status === 'offered' ? {
|
|
155
|
+
position: await waitlistPosition(repo, b), hours: WAITLIST_HOLD / 3_600_000,
|
|
156
|
+
until: b.holdUntil === null ? '' : untilText(b.holdUntil, d.tz, settings),
|
|
157
|
+
} : undefined;
|
|
129
158
|
return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
|
|
130
|
-
manageUrl:
|
|
159
|
+
manageUrl:mine ? origin + mine : staff ? adminBookingUrl(origin, b, kind) : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
|
|
160
|
+
mineLink: !!mine,
|
|
131
161
|
siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
|
|
132
|
-
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang });
|
|
162
|
+
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang, rsvpStaff: counts, waitlist });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The member's My events page for an event they host, when they can't manage bookings in the admin ('' otherwise). */
|
|
166
|
+
async function memberHostPath(repo: ContentRepository, e: CalEvent, email: string): Promise<string> {
|
|
167
|
+
const user = (await repo.listUsers()).find((u) => u.email && u.email.trim().toLowerCase() === email.trim().toLowerCase());
|
|
168
|
+
if (!user || !MEMBERS.enabled || !e.hostIds.includes(user.id)) return '';
|
|
169
|
+
return (await principalCan(repo, { user }, 'schedule.bookings')) ? '' : `${memberEventsPath()}/${encodeURIComponent(e.id)}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Who hears about an event's RSVPs (rsvp.notifyStaff): its hosts, else the
|
|
174
|
+
* contact email in Settings → Legal (then acms({ legal }), as legalOperator
|
|
175
|
+
* reads it), else the owners. Accounts that were removed, or whose role the
|
|
176
|
+
* site no longer has, are skipped. via: where the person did it.
|
|
177
|
+
*/
|
|
178
|
+
export async function rsvpStaff(repo: ContentRepository, e: CalEvent, settings: Settings, via: Booking['source']): Promise<RsvpStaffNotice | undefined> {
|
|
179
|
+
if (!e.rsvp.notifyStaff) return undefined;
|
|
180
|
+
const contact = settings.legal.contactEmail || LEGAL.contactEmail;
|
|
181
|
+
const users = e.hostIds.length || !contact ? (await repo.listUsers()).filter((u) => u.email && isRole(u.role)) : [];
|
|
182
|
+
const hosts = users.filter((u) => e.hostIds.includes(u.id)).map((u) => u.email);
|
|
183
|
+
const to = hosts.length ? hosts : contact ? [contact] : users.filter((u) => u.role === 'owner').map((u) => u.email);
|
|
184
|
+
return { to: [...new Set(to.map((a) => a.trim().toLowerCase()))].filter(Boolean), why: hosts.length ? 'hosts' : 'contact', via };
|
|
133
185
|
}
|
|
134
186
|
async function notify(repo: ContentRepository, _b: Booking, _kinds: BookingEmail[], opts: FlowOptions, _message?: string) {
|
|
135
187
|
// Repository mutations already committed their outbox jobs atomically.
|
|
@@ -195,22 +247,29 @@ export function guestChanges(b: Booking, link: BookingLink | null, now = Date.no
|
|
|
195
247
|
const active = b.status === 'pending' || b.status === 'confirmed';
|
|
196
248
|
const cutoff = b.kind === 'booking' ? (link?.changeCutoff ?? 0) : 0;
|
|
197
249
|
const closed = active && cutoff > 0 && b.start - now < cutoff * 3_600_000;
|
|
198
|
-
|
|
250
|
+
// a place in line or a held spot can be left until the date ends
|
|
251
|
+
const open = (active || b.status === 'waitlisted' || b.status === 'offered') && b.end > now && !closed;
|
|
199
252
|
return {
|
|
200
253
|
cancel: open,
|
|
201
|
-
reschedule: open && b.kind === 'booking' && b.start > now && !!link?.active,
|
|
254
|
+
reschedule: open && active && b.kind === 'booking' && b.start > now && !!link?.active,
|
|
255
|
+
// a spot offered from the waitlist, still held for them
|
|
256
|
+
confirm: b.status === 'offered' && (b.holdUntil ?? 0) > now && b.end > now,
|
|
202
257
|
// hours before the start when guest changes close; 0 = until it ends
|
|
203
258
|
cutoffHours: cutoff,
|
|
204
259
|
closed,
|
|
205
260
|
};
|
|
206
261
|
}
|
|
207
262
|
|
|
208
|
-
|
|
263
|
+
// via: where they cancelled (the manage page, or an app)
|
|
264
|
+
export async function guestCancel(repo: ContentRepository, b: Booking, opts: FlowOptions, via: Booking['source'] = 'web'): Promise<Booking> {
|
|
209
265
|
const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
|
|
210
266
|
if (b.status === 'cancelled') return b;
|
|
211
267
|
const can = guestChanges(b, link, opts.now);
|
|
212
268
|
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.');
|
|
213
|
-
|
|
269
|
+
// the person cancelled an RSVP: its hosts hear about it (staff cancelling never emails staff;
|
|
270
|
+
// leaving the waitlist or a held spot isn't an RSVP to them)
|
|
271
|
+
const event = b.kind === 'rsvp' && b.eventId && (b.status === 'pending' || b.status === 'confirmed') ? await repo.getEvent(b.eventId) : null;
|
|
272
|
+
return setBookingStatus(repo, b, 'cancelled', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
|
|
214
273
|
}
|
|
215
274
|
|
|
216
275
|
/** the link as a guest moving a booking sees it: no new time inside the change cutoff */
|
|
@@ -266,14 +325,34 @@ export async function createBlock(repo: ContentRepository, resource: Resource, s
|
|
|
266
325
|
});
|
|
267
326
|
}
|
|
268
327
|
|
|
328
|
+
// pending, confirmed and held (offered) RSVPs take seats, as in the capacity
|
|
329
|
+
// check; a place in line doesn't
|
|
330
|
+
const seated = (s: BookingStatus) => s === 'pending' || s === 'confirmed' || s === 'offered';
|
|
269
331
|
export async function seatsTaken(repo: ContentRepository, eventId: string, start: number): Promise<number> {
|
|
270
|
-
const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['pending', 'confirmed'], kinds: ['rsvp'] });
|
|
332
|
+
const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['pending', 'confirmed', 'offered'], kinds: ['rsvp'] });
|
|
271
333
|
return list.filter((b) => b.start === start).reduce((n, b) => n + b.seats, 0);
|
|
272
334
|
}
|
|
273
335
|
|
|
336
|
+
/** whether a full date takes places in line: the event keeps a waitlist and has a capacity */
|
|
337
|
+
export const waitlistOn = (e: CalEvent) => e.rsvp.enabled && e.rsvp.waitlist && e.rsvp.capacity > 0;
|
|
338
|
+
|
|
339
|
+
/** a date's waitlist in order: who joined first, then by id */
|
|
340
|
+
export async function waitlistLine(repo: ContentRepository, eventId: string, start: number): Promise<Booking[]> {
|
|
341
|
+
const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['waitlisted'], kinds: ['rsvp'] });
|
|
342
|
+
return list.filter((b) => b.start === start).sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** a waitlisted RSVP's place in its date's line (1 = next), else null */
|
|
346
|
+
export async function waitlistPosition(repo: ContentRepository, b: Booking): Promise<number | null> {
|
|
347
|
+
if (b.status !== 'waitlisted') return null;
|
|
348
|
+
const i = (await waitlistLine(repo, b.eventId, b.start)).findIndex((x) => x.id === b.id);
|
|
349
|
+
return i < 0 ? null : i + 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
274
352
|
export async function createRsvp(
|
|
275
353
|
repo: ContentRepository,
|
|
276
|
-
|
|
354
|
+
// waitlist: a full date (fewer seats left than asked) takes a place in line instead, when the event keeps one
|
|
355
|
+
args: { event: CalEvent; start: number; end: number; seats: number; booker: BookerInput; source?: Booking['source']; waitlist?: boolean },
|
|
277
356
|
opts: FlowOptions
|
|
278
357
|
): Promise<Booking> {
|
|
279
358
|
const e = args.event;
|
|
@@ -294,15 +373,22 @@ export async function createRsvp(
|
|
|
294
373
|
if (formResponse) booker.answers = displayAnswers(formResponse);
|
|
295
374
|
// one RSVP per address and date on public channels; staff may add more
|
|
296
375
|
const once = args.source !== 'admin';
|
|
297
|
-
const
|
|
298
|
-
|
|
376
|
+
const source = args.source ?? 'web';
|
|
377
|
+
// the hosts' email is queued with the RSVP: a duplicate or a replay inserts nothing, so it emails no one
|
|
378
|
+
const data = {
|
|
379
|
+
kind: 'rsvp' as const, resourceId: '', linkId: '', eventId: e.id, status: (e.rsvp.autoConfirm ? 'confirmed' : 'pending') as BookingStatus,
|
|
299
380
|
start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
|
|
300
|
-
formResponse, notes: '', manageToken: token(), source
|
|
301
|
-
}
|
|
381
|
+
formResponse, notes: '', manageToken: token(), source, locale: normalizePageLocale(args.booker.locale ?? ''),
|
|
382
|
+
};
|
|
383
|
+
let b = await repo.createBooking(data, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency, staff: await rsvpStaff(repo, e, settings, source) });
|
|
384
|
+
// full: a place in line, which takes no seats and emails no staff (still one per address and date)
|
|
385
|
+
if (!b && args.waitlist && waitlistOn(e) && !(opts.idempotency && await repo.getBookingRequest(opts.idempotency.keyHash))) {
|
|
386
|
+
b = await repo.createBooking({ ...data, status: 'waitlisted' }, { oncePerEmail: once, idempotency: opts.idempotency });
|
|
387
|
+
}
|
|
302
388
|
if (!b) {
|
|
303
389
|
// a retry of a request that just committed: the caller replays that one
|
|
304
390
|
const retry = opts.idempotency ? await repo.getBookingRequest(opts.idempotency.keyHash) : null;
|
|
305
|
-
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;
|
|
391
|
+
const mine = once && !retry ? (await repo.listBookings({ eventId: e.id, email: booker.email, from: args.start - 1, to: args.start + 1, statuses: ['pending', 'confirmed', 'offered', 'waitlisted'], kinds: ['rsvp'] })).find((x) => x.start === args.start) : undefined;
|
|
306
392
|
if (mine) {
|
|
307
393
|
await resendManageLink(repo, mine, args.booker.locale === undefined ? mine.locale : normalizePageLocale(args.booker.locale), opts);
|
|
308
394
|
// anyone can type an address: say only that it has one
|
|
@@ -310,12 +396,101 @@ export async function createRsvp(
|
|
|
310
396
|
}
|
|
311
397
|
throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
|
|
312
398
|
}
|
|
313
|
-
await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
|
|
399
|
+
await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received', 'staff-rsvp'], opts);
|
|
314
400
|
await hook(repo, 'booking.created', b, opts);
|
|
315
401
|
if (b.status === 'confirmed') await hook(repo, 'booking.confirmed', b, opts);
|
|
316
402
|
return b;
|
|
317
403
|
}
|
|
318
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Offers a date's freed seats to its waitlist, in order: each person whose
|
|
407
|
+
* seats fit what's free gets them held for WAITLIST_HOLD, with an email to
|
|
408
|
+
* confirm; larger requests keep their place. Runs after anything that frees
|
|
409
|
+
* seats (a cancellation, a decline, a spot that ran out, a capacity raised)
|
|
410
|
+
* and from the scheduler. Only while the event keeps a waitlist and before
|
|
411
|
+
* the date starts, past its RSVP deadline too: those in line joined in time.
|
|
412
|
+
* Each offer re-checks the seats in the database, so racing runs (or a new
|
|
413
|
+
* RSVP) never take more than the capacity. Returns how many it offered.
|
|
414
|
+
*/
|
|
415
|
+
export async function offerWaitlist(repo: ContentRepository, eventId: string, start: number, opts: FlowOptions): Promise<number> {
|
|
416
|
+
const now = opts.now ?? Date.now();
|
|
417
|
+
if (start <= now) return 0;
|
|
418
|
+
const line = await waitlistLine(repo, eventId, start);
|
|
419
|
+
if (!line.length) return 0;
|
|
420
|
+
const e = await repo.getEvent(eventId);
|
|
421
|
+
// turning the waitlist off only stops new people joining: those already in line still get their turn
|
|
422
|
+
if (!e || !e.rsvp.enabled || e.cancelled) return 0;
|
|
423
|
+
if (e.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, start, await repo.getSettings()))) return 0;
|
|
424
|
+
const cap = e.rsvp.capacity;
|
|
425
|
+
const free = async () => (cap ? cap - await seatsTaken(repo, eventId, start) : Infinity);
|
|
426
|
+
let left = await free(), offered = 0;
|
|
427
|
+
for (const b of line) {
|
|
428
|
+
if (left <= 0) break;
|
|
429
|
+
if (b.seats > left) continue;
|
|
430
|
+
if (await repo.offerBooking(b.id, now + WAITLIST_HOLD, cap)) { left -= b.seats; offered++; }
|
|
431
|
+
// someone took seats meanwhile: count again
|
|
432
|
+
else left = await free();
|
|
433
|
+
}
|
|
434
|
+
if (offered) await kickEmail(repo, opts.locals, opts.waitUntil);
|
|
435
|
+
return offered;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** every upcoming date of one event with people in line (after an edit that may have freed seats) */
|
|
439
|
+
export async function offerEventWaitlist(repo: ContentRepository, eventId: string, opts: FlowOptions): Promise<number> {
|
|
440
|
+
const now = opts.now ?? Date.now();
|
|
441
|
+
const starts = new Set((await repo.listBookings({ eventId, from: now, statuses: ['waitlisted'], kinds: ['rsvp'] })).map((b) => b.start));
|
|
442
|
+
let offered = 0;
|
|
443
|
+
for (const start of starts) offered += await offerWaitlist(repo, eventId, start, opts);
|
|
444
|
+
return offered;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The person takes the spot held for them (manage page, app): confirmed, or
|
|
449
|
+
* pending when the event approves each RSVP, with the usual email and the
|
|
450
|
+
* hosts' "New RSVP" one. Past its hold the spot is released (410) and goes
|
|
451
|
+
* to the next person in line. Repeating it is harmless.
|
|
452
|
+
*/
|
|
453
|
+
export async function confirmOffer(repo: ContentRepository, b: Booking, opts: FlowOptions, via: Booking['source'] = 'web'): Promise<Booking> {
|
|
454
|
+
const now = opts.now ?? Date.now();
|
|
455
|
+
if (b.status === 'confirmed' || b.status === 'pending') return b;
|
|
456
|
+
if (b.status === 'offered' && (b.holdUntil ?? 0) <= now) {
|
|
457
|
+
await releaseOffer(repo, b, opts);
|
|
458
|
+
throw new BookingError(410, 'This held spot was released.');
|
|
459
|
+
}
|
|
460
|
+
if (b.status === 'expired') throw new BookingError(410, 'This held spot was released.');
|
|
461
|
+
if (b.status !== 'offered') throw new BookingError(409, 'There’s no held spot to confirm.');
|
|
462
|
+
if (b.end <= now) throw new BookingError(410, 'This event has already happened.');
|
|
463
|
+
const event = b.eventId ? await repo.getEvent(b.eventId) : null;
|
|
464
|
+
try {
|
|
465
|
+
return await setBookingStatus(repo, b, event?.rsvp.autoConfirm === false ? 'pending' : 'confirmed', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if ((await repo.getBooking(b.id))?.status === 'expired') throw new BookingError(410, 'This held spot was released.');
|
|
468
|
+
throw err;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// a held spot nobody confirmed in time: expired (no email; only if it's still
|
|
473
|
+
// offered), and its seats go to the next person in line
|
|
474
|
+
async function releaseOffer(repo: ContentRepository, b: Booking, opts: FlowOptions) {
|
|
475
|
+
if (await repo.expireBooking(b.id, opts.now ?? Date.now())) await offerWaitlist(repo, b.eventId, b.start, opts);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// cron: held spots past their time run out, and every upcoming date with
|
|
479
|
+
// people in line is offered what's free (catching up on seats freed by edits,
|
|
480
|
+
// or by a change that lost a race)
|
|
481
|
+
export async function sweepWaitlist(repo: ContentRepository, opts: FlowOptions): Promise<number> {
|
|
482
|
+
const now = opts.now ?? Date.now();
|
|
483
|
+
const list = await repo.listBookings({ from: now, statuses: ['waitlisted', 'offered'], kinds: ['rsvp'] });
|
|
484
|
+
const dates = new Map<string, { eventId: string; start: number }>();
|
|
485
|
+
for (const b of list) {
|
|
486
|
+
if (b.status === 'offered' && (b.holdUntil ?? 0) <= now) await repo.expireBooking(b.id, now);
|
|
487
|
+
dates.set(`${b.eventId}:${b.start}`, { eventId: b.eventId, start: b.start });
|
|
488
|
+
}
|
|
489
|
+
let offered = 0;
|
|
490
|
+
for (const d of dates.values()) offered += await offerWaitlist(repo, d.eventId, d.start, opts);
|
|
491
|
+
return offered;
|
|
492
|
+
}
|
|
493
|
+
|
|
319
494
|
// The address gets its RSVP's own link again, in the language it just used,
|
|
320
495
|
// at most once per 10 minutes per RSVP so the form can't flood an inbox.
|
|
321
496
|
async function resendManageLink(repo: ContentRepository, b: Booking, locale: string, opts: FlowOptions) {
|
|
@@ -330,9 +505,14 @@ const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
|
|
|
330
505
|
confirmed: ['cancelled'],
|
|
331
506
|
declined: ['confirmed'],
|
|
332
507
|
cancelled: ['confirmed'],
|
|
508
|
+
// only offerWaitlist offers a place in line, and only the clock expires a held spot
|
|
509
|
+
waitlisted: ['cancelled'],
|
|
510
|
+
offered: ['confirmed', 'pending', 'cancelled'],
|
|
511
|
+
expired: [],
|
|
333
512
|
};
|
|
334
513
|
|
|
335
|
-
|
|
514
|
+
// staff: who to tell that the person cancelled (guestCancel); staff changes tell no one
|
|
515
|
+
export async function setBookingStatus(repo: ContentRepository, b: Booking, status: BookingStatus, opts: FlowOptions, message?: string, staff?: RsvpStaffNotice): Promise<Booking> {
|
|
336
516
|
if (b.status === status) return b;
|
|
337
517
|
if (!TRANSITIONS[b.status].includes(status)) throw new BookingError(409, `A ${b.status} booking can’t become ${status}.`);
|
|
338
518
|
// re-confirming a declined/cancelled booking must re-check the slot
|
|
@@ -341,13 +521,17 @@ export async function setBookingStatus(repo: ContentRepository, b: Booking, stat
|
|
|
341
521
|
.some((x) => x.id !== b.id && x.kind !== 'rsvp' && x.holdStart < b.holdEnd && x.holdEnd > b.holdStart);
|
|
342
522
|
if (clash) throw new BookingError(409, 'That time has been booked since — it can’t be restored.');
|
|
343
523
|
}
|
|
344
|
-
|
|
524
|
+
// only from the status it was read in: a spot that ran out meanwhile can't be confirmed
|
|
525
|
+
await repo.updateBooking(b.id, { status, from: b.status, notificationMessage: message, staff });
|
|
345
526
|
const next = (await repo.getBooking(b.id))!;
|
|
346
527
|
if (!next || next.status !== status) throw new BookingError(409, 'This booking changed. Refresh and try again.');
|
|
347
528
|
if (b.kind !== 'block') {
|
|
348
529
|
await notify(repo, next, [status === 'confirmed' ? 'confirmed' : status === 'declined' ? 'declined' : 'cancelled'], opts, message);
|
|
349
|
-
|
|
530
|
+
// a held spot taken up for approval is no webhook yet (approving it is)
|
|
531
|
+
if (status !== 'pending') await hook(repo, status === 'confirmed' ? 'booking.confirmed' : status === 'declined' ? 'booking.declined' : 'booking.cancelled', next, opts);
|
|
350
532
|
}
|
|
533
|
+
// the seats it held go to the next people in line
|
|
534
|
+
if (b.kind === 'rsvp' && seated(b.status) && !seated(status)) await offerWaitlist(repo, b.eventId, b.start, opts);
|
|
351
535
|
return next;
|
|
352
536
|
}
|
|
353
537
|
|
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('#')),
|
|
@@ -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 }
|