@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
|
@@ -23,8 +23,9 @@ export const bookingInput = z.union([
|
|
|
23
23
|
z.object({ ...common, resource: z.string().min(1).max(200), link: z.string().min(1).max(200),
|
|
24
24
|
minutes: z.number().int().min(1).max(44640).optional(),
|
|
25
25
|
}).strict(),
|
|
26
|
-
// formVersion: from GET /events/{slug}/rsvp; required when the event asks questions
|
|
27
|
-
|
|
26
|
+
// formVersion: from GET /events/{slug}/rsvp; required when the event asks questions.
|
|
27
|
+
// waitlist: a full date takes a place in line instead (when the event keeps a waitlist)
|
|
28
|
+
z.object({ ...common, event: z.string().min(1).max(200), seats: z.number().int().min(1).max(1000).default(1), formVersion: z.string().max(100).optional(), waitlist: z.boolean().optional() }).strict(),
|
|
28
29
|
]);
|
|
29
30
|
function canonical(value: unknown): string {
|
|
30
31
|
if (value && typeof value === 'object' && !Array.isArray(value)) return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k,v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
|
|
@@ -67,7 +68,7 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
67
68
|
// the app must have seen the current questions: a changed form then answers 409, as on the website
|
|
68
69
|
// (closed dates fall through to createRsvp's own answer)
|
|
69
70
|
if (!body.formVersion && rsvpQuestions(event) && !rsvpClosed(occurrence, settings)) throw new ApiError(400, 'This event asks questions. Send formVersion from GET /api/v1/events/{slug}/rsvp with the answers.');
|
|
70
|
-
booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker, source: 'app' }, flow);
|
|
71
|
+
booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker, source: 'app', waitlist: body.waitlist }, flow);
|
|
71
72
|
} else {
|
|
72
73
|
const resource = await repo.getResourceBySlug(body.resource);
|
|
73
74
|
const link = resource ? await repo.getBookingLinkBySlug(resource.id, body.link) : null;
|
|
@@ -4,7 +4,7 @@ import { publicTags, tagFilter, type BookingLink, type CalEvent, type Calendar,
|
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
|
|
6
6
|
import { calendarToJson, eventToJson } from '../calendar/api';
|
|
7
|
-
import { eventIsListed, eventIsPublic, expandEvents } from '../calendar/events';
|
|
7
|
+
import { eventIsListed, eventIsPublic, expandEvents, featuredOccurrence } from '../calendar/events';
|
|
8
8
|
import { availabilityContext } from '../booking/service';
|
|
9
9
|
import { addDaysYmd, describeLink, linkLengths, localMidnight, slotsForDay, ymd } from '../booking/availability';
|
|
10
10
|
import { siteOrigin } from '../seo';
|
|
@@ -65,6 +65,28 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
65
65
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
+
// one event by slug (or id), whatever its date: apps open it from a link, a
|
|
69
|
+
// push or an announcement, often weeks ahead. `on` ('YYYY-MM-DD', like the
|
|
70
|
+
// website's ?on=) picks that day's occurrence; otherwise the next one, or the
|
|
71
|
+
// most recent when it's over.
|
|
72
|
+
export const eventDetailQuery = z.object({ locale: localeQuery, on: isoDay.optional() }).strict();
|
|
73
|
+
export async function getEventDetail(repo: ContentRepository, url: URL, ref: string) {
|
|
74
|
+
requireCalendar();
|
|
75
|
+
const q = eventDetailQuery.parse(queryParams(url));
|
|
76
|
+
const locale = mobileLocale(q.locale);
|
|
77
|
+
const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
78
|
+
const found = ref && ref.length <= 200 ? await repo.getEventBySlug(ref) ?? await repo.getEvent(ref) : null;
|
|
79
|
+
const all = new Map(calendars.map(c => [c.id, c]));
|
|
80
|
+
if (!found || !eventIsPublic(found, all)) throw new ApiError(404, 'Event not found.');
|
|
81
|
+
const e = localizeDoc(found, locale ?? undefined);
|
|
82
|
+
const o = featuredOccurrence(e, settings, Date.now(), q.on);
|
|
83
|
+
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public').map(c => [c.id, c]));
|
|
84
|
+
return {
|
|
85
|
+
locale,
|
|
86
|
+
occurrence: o ? { id: `${e.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, cancelled: o.cancelled } : null,
|
|
87
|
+
event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
68
90
|
// e is already localized; publicCalendars limits which calendars are named
|
|
69
91
|
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null) {
|
|
70
92
|
const event = eventToJson(e, origin, publicCalendars, settings.timezone);
|
package/src/lib/mobile/manage.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { eventTimeZone, locationLine } from '../calendar/events';
|
|
|
6
6
|
import { resourceZone, linkLengths } from '../booking/availability';
|
|
7
7
|
import { bookingIcs, bookingLanguage, bookingManagePath } from '../booking/emails';
|
|
8
8
|
import { localePath } from '../i18n';
|
|
9
|
-
import { guestCancel, guestChanges, guestMoveLink, guestReschedule } from '../booking/service';
|
|
9
|
+
import { confirmOffer, guestCancel, guestChanges, guestMoveLink, guestReschedule, waitlistPosition } from '../booking/service';
|
|
10
10
|
import { siteOrigin } from '../seo';
|
|
11
11
|
import { waitUntilFrom } from '../webhooks';
|
|
12
12
|
import { isoDay, queryParams } from './http';
|
|
@@ -44,7 +44,8 @@ async function managed(c: PublicContext): Promise<Managed> {
|
|
|
44
44
|
return { booking, settings, resource, link, event, origin: siteOrigin(settings, c.url) };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// position: a waitlisted RSVP's place in line (view() can't query)
|
|
48
|
+
function view(m: Managed, booking = m.booking, position: number | null = null) {
|
|
48
49
|
const { resource, link, event, settings, origin } = m;
|
|
49
50
|
const can = guestChanges(booking, link);
|
|
50
51
|
return {
|
|
@@ -55,6 +56,8 @@ function view(m: Managed, booking = m.booking) {
|
|
|
55
56
|
timezone: event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC',
|
|
56
57
|
seats: booking.seats, name: booking.name, email: booking.email, phone: booking.phone, answers: booking.answers,
|
|
57
58
|
createdAt: new Date(booking.createdAt).toISOString(),
|
|
59
|
+
// in line: its place (1 = next); a held spot: until when
|
|
60
|
+
position, holdUntil: booking.status === 'offered' && booking.holdUntil !== null ? new Date(booking.holdUntil).toISOString() : null,
|
|
58
61
|
},
|
|
59
62
|
title: event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
|
|
60
63
|
location: event ? locationLine(event.location) : resource?.name ?? '',
|
|
@@ -71,11 +74,22 @@ function view(m: Managed, booking = m.booking) {
|
|
|
71
74
|
}
|
|
72
75
|
const flow = (m: Managed, c: PublicContext) => ({ origin: m.origin, locals: c.locals, waitUntil: waitUntilFrom(c.locals) });
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
const placed = async (c: PublicContext, m: Managed, booking = m.booking) => view(m, booking, await waitlistPosition(c.repo, booking));
|
|
78
|
+
|
|
79
|
+
export async function getManaged(c: PublicContext) {
|
|
80
|
+
const m = await managed(c);
|
|
81
|
+
return placed(c, m);
|
|
82
|
+
}
|
|
75
83
|
|
|
76
84
|
export async function cancelManaged(c: PublicContext) {
|
|
77
85
|
const m = await managed(c);
|
|
78
|
-
return view(m, await guestCancel(c.repo, m.booking, flow(m, c)));
|
|
86
|
+
return view(m, await guestCancel(c.repo, m.booking, flow(m, c), 'app'));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// take the spot the waitlist offered them; past its hold, 410 and it goes to the next person
|
|
90
|
+
export async function confirmManaged(c: PublicContext) {
|
|
91
|
+
const m = await managed(c);
|
|
92
|
+
return view(m, await confirmOffer(c.repo, m.booking, flow(m, c), 'app'));
|
|
79
93
|
}
|
|
80
94
|
|
|
81
95
|
export const manageAvailabilityQuery = z.object({
|
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
3
3
|
import type { ContentRepository } from '../repo/types';
|
|
4
4
|
import { contentQuery, contentDetailQuery } from './content';
|
|
5
|
-
import { eventsQuery, availabilityQuery } from './calendar';
|
|
5
|
+
import { eventsQuery, eventDetailQuery, availabilityQuery } from './calendar';
|
|
6
6
|
import { bookingInput } from './bookings';
|
|
7
7
|
import { rsvpQuery } from './rsvp';
|
|
8
8
|
import { pagesQuery } from './pages';
|
|
@@ -51,6 +51,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
51
51
|
['/content/{id}', 'Published content detail', contentDetailQuery, ref('Content')],
|
|
52
52
|
['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
|
|
53
53
|
['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
|
|
54
|
+
['/events/{slug}', 'One public event by slug or id, whatever its date, with its next occurrence (or the one on `on`, YYYY-MM-DD; the most recent once it is over); requires calendar', eventDetailQuery, ref('EventDetail')],
|
|
54
55
|
['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
|
|
55
56
|
['/events/{slug}/rsvp', 'RSVP status of one date (start = the occurrence start exactly): open or why not, live seats, the questions and their version; never cached; requires calendar', rsvpQuery, ref('RsvpStatus'), false],
|
|
56
57
|
['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
|
|
@@ -79,7 +80,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
79
80
|
paths['/api/v1/bookings'] = {
|
|
80
81
|
post: {
|
|
81
82
|
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. An RSVP from an email that already has one for that date answers 409 with code duplicate (and emails that RSVP’s link again); past the event’s RSVP deadline, 410.',
|
|
83
|
+
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409. An RSVP from an email that already has one for that date (a place on its waitlist too) answers 409 with code duplicate (and emails that RSVP’s link again); past the event’s RSVP deadline, 410. A full date answers 409, unless the event keeps a waitlist (RsvpStatus.waitlist.enabled) and the RSVP sends waitlist: true: then it takes a place in line (status waitlisted, no seats) and is emailed when a spot opens.',
|
|
83
84
|
parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
|
|
84
85
|
requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
|
|
85
86
|
responses: { ...errors,
|
|
@@ -103,7 +104,9 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
103
104
|
const cors = { options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } } };
|
|
104
105
|
const manageNote = 'Requires the calendar and the owner switch bookingChanges. Same rules and emails as the booking’s web page; a booking type’s change cutoff closes guest changes that many hours before the start.';
|
|
105
106
|
paths['/api/v1/bookings/manage'] = { get: priv('The booker’s own booking: status, time, place, what they may still change', ref('ManagedBooking'), { header: bookingToken, description: manageNote }), ...cors };
|
|
106
|
-
paths['/api/v1/bookings/manage/cancel'] = { post: priv('Cancel the booking (repeating is harmless)', ref('ManagedBooking'), { header: bookingToken, write: true, description: manageNote }), ...cors };
|
|
107
|
+
paths['/api/v1/bookings/manage/cancel'] = { post: priv('Cancel the booking (repeating is harmless); for an RSVP on the waitlist, leave it or release a held spot', ref('ManagedBooking'), { header: bookingToken, write: true, description: manageNote }), ...cors };
|
|
108
|
+
paths['/api/v1/bookings/manage/confirm'] = { post: priv('Take a spot the waitlist offered (status offered, can.confirm): confirmed, or pending when the event approves RSVPs; repeating is harmless', ref('ManagedBooking'), { header: bookingToken, write: true,
|
|
109
|
+
description: `${manageNote} Past its holdUntil the spot was released (410) and goes to the next person in line; a booking with no held spot answers 409.` }), ...cors };
|
|
107
110
|
paths['/api/v1/bookings/manage/availability'] = { get: priv('Open times for moving the booking (its own time counts as free)', ref('Availability'), { header: bookingToken, query: manageAvailabilityQuery, description: manageNote }), ...cors };
|
|
108
111
|
paths['/api/v1/bookings/manage/reschedule'] = { post: priv('Move the booking to a new start (room bookings, not RSVPs)', ref('ManagedBooking'), { header: bookingToken, body: rescheduleInput, write: true, description: manageNote }), ...cors };
|
|
109
112
|
paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
|
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { CalEvent, Settings } from '../schema';
|
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { answerFields } from '../forms/schema';
|
|
6
6
|
import { eventIsPublic, expandEvents, rsvpDeadline, type Occurrence } from '../calendar/events';
|
|
7
|
-
import { seatsTaken } from '../booking/service';
|
|
7
|
+
import { seatsTaken, waitlistLine, waitlistOn } from '../booking/service';
|
|
8
8
|
import { localizeDoc } from '../translations';
|
|
9
9
|
import { siteLang } from '../dates';
|
|
10
10
|
import { rsvpStrings } from '../calendar/rsvp-words';
|
|
@@ -14,9 +14,11 @@ import { formJson } from './forms';
|
|
|
14
14
|
import { localeQuery, mobileLocale } from './locale';
|
|
15
15
|
|
|
16
16
|
// RSVP status of one date of an event, for apps: whether it takes RSVPs now,
|
|
17
|
-
// live seats and the questions with their version. The same
|
|
18
|
-
// event's web page: open until the event's deadline or the date
|
|
19
|
-
//
|
|
17
|
+
// live seats, its waitlist and the questions with their version. The same
|
|
18
|
+
// rules as the event's web page: open until the event's deadline or the date
|
|
19
|
+
// ends; a full date with a waitlist takes places in line (POST /bookings with
|
|
20
|
+
// waitlist: true). Never cached; seats change with every RSVP. POST /bookings
|
|
21
|
+
// saves one.
|
|
20
22
|
|
|
21
23
|
/** a public event (slug or id) and its occurrence starting exactly at `start` */
|
|
22
24
|
export async function rsvpOccurrence(repo: ContentRepository, ref: string, start: string): Promise<Occurrence> {
|
|
@@ -52,8 +54,9 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
52
54
|
const o = await rsvpOccurrence(repo, slug, q.start);
|
|
53
55
|
const settings = await repo.getSettings();
|
|
54
56
|
const { rsvp } = o.event;
|
|
55
|
-
// pending and
|
|
57
|
+
// pending, confirmed and held (waitlist) RSVPs take seats, as in the capacity check
|
|
56
58
|
const going = rsvp.enabled ? await seatsTaken(repo, o.event.id, o.start) : 0;
|
|
59
|
+
const line = waitlistOn(o.event) ? await waitlistLine(repo, o.event.id, o.start) : [];
|
|
57
60
|
const left = rsvp.capacity ? Math.max(0, rsvp.capacity - going) : null;
|
|
58
61
|
const closed = rsvpClosed(o, settings);
|
|
59
62
|
const deadline = rsvp.enabled ? rsvpDeadline(o, settings) : null;
|
|
@@ -74,6 +77,8 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
74
77
|
closesAt: deadline === null ? null : new Date(deadline).toISOString(),
|
|
75
78
|
// seats one RSVP may take right now
|
|
76
79
|
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
80
|
+
// a full date takes places in line: how many people wait, for how many seats, and the most one may ask for
|
|
81
|
+
waitlist: { enabled: waitlistOn(o.event), waiting: line.length, seats: line.reduce((n, b) => n + b.seats, 0), maxSeats: rsvp.maxSeats || 1 },
|
|
77
82
|
form: form ? { ...await formJson(form), submitLabel } : null,
|
|
78
83
|
};
|
|
79
84
|
}
|
|
@@ -30,7 +30,7 @@ export const Page = ContentSummary.extend({
|
|
|
30
30
|
export const Event = CalEvent.pick({
|
|
31
31
|
id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
|
|
32
32
|
allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
|
|
33
|
-
}).extend({ rsvp: CalEvent.shape.rsvp.optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
|
|
33
|
+
}).extend({ rsvp: CalEvent.shape.rsvp.removeDefault().omit({ notifyStaff: true }).default({}).optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
|
|
34
34
|
calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
|
|
35
35
|
// push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
|
|
36
36
|
notify: z.object({ changes: strings }),
|
|
@@ -40,6 +40,7 @@ export const Events = z.object({ locale, timezone: str, from: date, to: date, pa
|
|
|
40
40
|
});
|
|
41
41
|
const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
|
|
42
42
|
const ContentHit = ContentSummary.extend({ snippet: Snippet });
|
|
43
|
+
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event });
|
|
43
44
|
export const SearchResults = z.object({ locale, query: str,
|
|
44
45
|
posts: z.array(ContentHit), pages: z.array(ContentHit),
|
|
45
46
|
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event })),
|
|
@@ -76,13 +77,20 @@ export const RsvpStatus = z.object({
|
|
|
76
77
|
going: z.number().int(), left: z.number().int().nullable(), capacity: z.number().int(), maxSeats: z.number().int(), autoConfirm: bool,
|
|
77
78
|
// the event's RSVP deadline for this date (null: open until it ends)
|
|
78
79
|
closesAt: date.nullable(),
|
|
80
|
+
// enabled: a full date (reason full) takes places in line; waiting: people in line, seats: theirs; maxSeats: the most one may ask for
|
|
81
|
+
waitlist: z.object({ enabled: bool, waiting: z.number().int(), seats: z.number().int(), maxSeats: z.number().int() }),
|
|
79
82
|
form: Form.omit({ id: true }).nullable(),
|
|
80
83
|
});
|
|
81
|
-
|
|
84
|
+
// confirm: a spot offered from the waitlist is still held for them
|
|
85
|
+
const GuestChanges = z.object({ cancel: bool, reschedule: bool, confirm: bool, cutoffHours: z.number().int(), closed: bool });
|
|
86
|
+
// waitlisted: in line for a full date; offered: a freed spot held until holdUntil; expired: not confirmed in time
|
|
87
|
+
const BookingStatus = z.enum(['pending', 'confirmed', 'declined', 'cancelled', 'waitlisted', 'offered', 'expired']);
|
|
82
88
|
export const ManagedBooking = z.object({
|
|
83
89
|
booking: z.object({
|
|
84
|
-
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status:
|
|
90
|
+
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status: BookingStatus,
|
|
85
91
|
start: date, end: date, allDay: bool, timezone: str, seats: z.number().int(), name: str, email: str, phone: str, answers: z.record(str), createdAt: date,
|
|
92
|
+
// waitlisted: the place in line (1 = next); offered: until when the spot is held
|
|
93
|
+
position: z.number().int().nullable(), holdUntil: date.nullable(),
|
|
86
94
|
}),
|
|
87
95
|
title: str, location: str,
|
|
88
96
|
resource: z.object({ slug: str, name: str }).nullable(),
|
|
@@ -95,7 +103,7 @@ export const ManagedBooking = z.object({
|
|
|
95
103
|
export const GuestCodeSent = z.object({ sent: z.literal(true), expiresInMinutes: z.number().int() });
|
|
96
104
|
export const GuestToken = z.object({ accessToken: str, tokenType: z.literal('Bearer'), email: str, idleDays: z.number().int() });
|
|
97
105
|
export const GuestBookings = z.object({ email: str, pagination: Pagination, items: z.array(z.object({
|
|
98
|
-
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status:
|
|
106
|
+
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status: BookingStatus,
|
|
99
107
|
start: date, end: date, seats: z.number().int(), title: str, manageToken: str, manageUrl: str, can: GuestChanges,
|
|
100
108
|
})) });
|
|
101
109
|
export const GuestSignedOut = z.object({ signedOut: z.literal(true) });
|
|
@@ -690,6 +690,11 @@ export const MIGRATIONS: Migration[] = [
|
|
|
690
690
|
{ version: 60, statements: [
|
|
691
691
|
`ALTER TABLE bookings ADD COLUMN locale TEXT NOT NULL DEFAULT ''`,
|
|
692
692
|
] },
|
|
693
|
+
// RSVP waitlists: an offered spot is held until hold_until (ms); null for
|
|
694
|
+
// everything else, so older rows need no backfill
|
|
695
|
+
{ version: 61, statements: [
|
|
696
|
+
`ALTER TABLE bookings ADD COLUMN hold_until INTEGER`,
|
|
697
|
+
] },
|
|
693
698
|
];
|
|
694
699
|
|
|
695
700
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { PermissionStore } from '../permissions/store';
|
|
|
11
11
|
import { BackupStore } from '../backup/store';
|
|
12
12
|
import { LiveConfig } from '../live/types';
|
|
13
13
|
import { ApiError } from '../api';
|
|
14
|
-
import { EmailStore, bookingJobs } from '../email/store';
|
|
14
|
+
import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
|
|
15
15
|
import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
|
|
16
16
|
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
17
17
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
@@ -212,9 +212,14 @@ function rowToBooking(r: Row): Booking {
|
|
|
212
212
|
formResponse: r.form_response ? json(r.form_response, undefined) : undefined,
|
|
213
213
|
answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web', locale: r.locale ?? '',
|
|
214
214
|
notificationVersion: Number(r.notification_version ?? 0), reminderSentAt: r.reminder_sent_at === null || r.reminder_sent_at === undefined ? null : Number(r.reminder_sent_at),
|
|
215
|
+
holdUntil: r.hold_until === null || r.hold_until === undefined ? null : Number(r.hold_until),
|
|
215
216
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
216
217
|
});
|
|
217
218
|
}
|
|
219
|
+
// RSVP statuses that take seats (a held offer too), and those that are the
|
|
220
|
+
// address's RSVP for a date (a place in line too)
|
|
221
|
+
const SEATED = "('pending','confirmed','offered')";
|
|
222
|
+
const RSVP_ACTIVE = "('pending','confirmed','offered','waitlisted')";
|
|
218
223
|
function rowToApiKey(r: Row): ApiKey {
|
|
219
224
|
return ApiKey.parse({
|
|
220
225
|
id: r.id, name: r.name, prefix: r.prefix, scopes: json(r.scopes, []),
|
|
@@ -446,7 +451,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
446
451
|
await this.ready;
|
|
447
452
|
// One SELECT snapshots bookings and their retry ledger together. No UI
|
|
448
453
|
// list limit; include tombstones for deleted bookings as well.
|
|
449
|
-
const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','locale','reminder_sent_at','notification_version','created_at','updated_at'];
|
|
454
|
+
const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','locale','reminder_sent_at','hold_until','notification_version','created_at','updated_at'];
|
|
450
455
|
const rows = await this.driver.all(`SELECT 'booking' AS type, json_object(${columns.map(c => `'${c}',${c}`).join(',')}) AS data FROM bookings
|
|
451
456
|
UNION ALL SELECT 'request', json_object('keyHash',key_hash,'requestHash',request_hash,'bookingId',booking_id,'createdAt',created_at) FROM mobile_booking_requests`);
|
|
452
457
|
return {
|
|
@@ -917,6 +922,13 @@ export class SqlRepository implements ContentRepository {
|
|
|
917
922
|
start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
|
|
918
923
|
end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
|
|
919
924
|
WHERE event_id=? AND kind='rsvp' AND status IN ('pending','confirmed')`,args:[id,siteTimezone,cancelled?1:0,single?1:0,id,single?1:0,id,id]},
|
|
925
|
+
// places in line and held spots move with the date too; only its cancellation emails them
|
|
926
|
+
{sql:`UPDATE bookings SET notification_version=notification_version+?,
|
|
927
|
+
timezone=COALESCE(NULLIF((SELECT timezone FROM events WHERE id=?),''),?),
|
|
928
|
+
status=CASE WHEN ? THEN 'cancelled' ELSE status END,
|
|
929
|
+
start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
|
|
930
|
+
end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
|
|
931
|
+
WHERE event_id=? AND kind='rsvp' AND status IN ('waitlisted','offered')`,args:[cancelled?1:0,id,siteTimezone,cancelled?1:0,single?1:0,id,single?1:0,id,id]},
|
|
920
932
|
...notifications]);
|
|
921
933
|
} else await this.driver.batch([eventWrite]);
|
|
922
934
|
await this.syncEventIndex(id);
|
|
@@ -948,7 +960,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
948
960
|
const mutation = crypto.randomUUID();
|
|
949
961
|
const notifications = bookingJobs(id,'cancelled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp'")}));
|
|
950
962
|
await this.driver.batch([
|
|
951
|
-
{sql
|
|
963
|
+
{sql:`UPDATE bookings SET status='cancelled',notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=? WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ${RSVP_ACTIVE}`,args:[mutation,Date.now(),id,start]},
|
|
952
964
|
...notifications,
|
|
953
965
|
]);
|
|
954
966
|
return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
|
|
@@ -972,7 +984,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
972
984
|
const mutation = crypto.randomUUID();
|
|
973
985
|
const notifications = bookingJobs(id,'cancelled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp'")}));
|
|
974
986
|
await this.driver.batch([
|
|
975
|
-
{sql
|
|
987
|
+
{sql:`UPDATE bookings SET status='cancelled',notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL WHERE event_id=? AND kind='rsvp' AND status IN ${RSVP_ACTIVE}`,args:[mutation,id]},
|
|
976
988
|
...notifications,
|
|
977
989
|
{ sql: 'DELETE FROM events WHERE id = ?', args: [id] },
|
|
978
990
|
{ sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [id] },
|
|
@@ -1078,9 +1090,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1078
1090
|
private bookingInsert(b: Booking): SqlStatement {
|
|
1079
1091
|
return {
|
|
1080
1092
|
sql: `INSERT INTO bookings (id, kind, resource_id, link_id, event_id, status, start_at, end_at, hold_start, hold_end, timezone, name, email, phone,
|
|
1081
|
-
seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response, locale) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1093
|
+
seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response, locale, hold_until) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1082
1094
|
args: [b.id, b.kind, b.resourceId, b.linkId, b.eventId, b.status, b.start, b.end, b.holdStart, b.holdEnd, b.timezone, b.name, b.email, b.phone,
|
|
1083
|
-
b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null, b.locale],
|
|
1095
|
+
b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null, b.locale, b.holdUntil],
|
|
1084
1096
|
};
|
|
1085
1097
|
}
|
|
1086
1098
|
async listResources() {
|
|
@@ -1221,12 +1233,13 @@ export class SqlRepository implements ContentRepository {
|
|
|
1221
1233
|
const holds = b.status === 'pending' || b.status === 'confirmed';
|
|
1222
1234
|
let write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE 1=1`, args: ins.args };
|
|
1223
1235
|
if (b.kind === 'rsvp') {
|
|
1224
|
-
|
|
1236
|
+
// a place in line takes no seats
|
|
1237
|
+
const cap = holds ? guard.capacity ?? 0 : 0;
|
|
1225
1238
|
write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')}
|
|
1226
|
-
WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN
|
|
1239
|
+
WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN ${SEATED}) + ? <= ?)`,
|
|
1227
1240
|
args: [...ins.args!,cap,b.eventId,b.start,b.seats,cap] };
|
|
1228
1241
|
// same statement, so two at once from one address can't both get in
|
|
1229
|
-
if (guard.oncePerEmail && holds && b.email) write = { sql: write.sql + ` AND NOT EXISTS (SELECT 1 FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND email=? AND status IN
|
|
1242
|
+
if (guard.oncePerEmail && (holds || b.status === 'waitlisted') && b.email) write = { sql: write.sql + ` AND NOT EXISTS (SELECT 1 FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND email=? AND status IN ${RSVP_ACTIVE})`,
|
|
1230
1243
|
args: [...write.args!,b.eventId,b.start,b.email] };
|
|
1231
1244
|
} else if (holds && b.resourceId) {
|
|
1232
1245
|
write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE NOT EXISTS (
|
|
@@ -1241,7 +1254,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1241
1254
|
SELECT ?,?,id,? FROM bookings WHERE id=?`, args: [keyHash, requestHash, now, b.id] });
|
|
1242
1255
|
}
|
|
1243
1256
|
// Booking, retry ledger and email outbox commit together on D1 and libSQL.
|
|
1244
|
-
await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : 'received', 0, true)]);
|
|
1257
|
+
await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : b.status === 'waitlisted' ? 'waitlisted' : 'received', 0, true), ...rsvpStaffJobs(b.id, 'staff-rsvp', guard.staff, 0)]);
|
|
1245
1258
|
return this.getBooking(b.id);
|
|
1246
1259
|
}
|
|
1247
1260
|
async moveBooking(id: string, t: { start: number; end: number; holdStart: number; holdEnd: number }) {
|
|
@@ -1276,9 +1289,37 @@ export class SqlRepository implements ContentRepository {
|
|
|
1276
1289
|
if (data.answers !== undefined) { sets.push('answers = ?'); args.push(JSON.stringify(data.answers)); }
|
|
1277
1290
|
if (data.reminderSentAt !== undefined) { sets.push('reminder_sent_at = ?'); args.push(data.reminderSentAt); }
|
|
1278
1291
|
args.push(id);
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1292
|
+
if (data.from && data.from !== current.status) return;
|
|
1293
|
+
// the guest's email: leaving the line (or a held spot) isn't a cancelled RSVP; a spot that ran out sends none
|
|
1294
|
+
const waiting = current.status === 'waitlisted' || current.status === 'offered';
|
|
1295
|
+
const kind = data.status === 'confirmed' ? 'confirmed' : data.status === 'declined' ? 'declined' : data.status === 'pending' ? 'received'
|
|
1296
|
+
: data.status === 'offered' ? 'offered' : data.status === 'expired' ? '' : waiting ? 'left' : 'cancelled';
|
|
1297
|
+
// staff: a confirmed offer is a new RSVP to them
|
|
1298
|
+
const staff = data.status === 'cancelled' ? 'staff-rsvp-cancelled' as const : data.status === 'confirmed' || data.status === 'pending' ? 'staff-rsvp' as const : null;
|
|
1299
|
+
await this.driver.batch([{sql:`UPDATE bookings SET ${sets.join(', ')} WHERE id = ? AND notification_version = ?${data.from ? ' AND status = ?' : ''}`,args:[...args,current.notificationVersion,...(data.from ? [data.from] : [])]},
|
|
1300
|
+
...(changed && kind ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : []),
|
|
1301
|
+
...(changed && staff ? rsvpStaffJobs(id,staff,data.staff,current.notificationVersion+1,mutation) : [])]);
|
|
1302
|
+
}
|
|
1303
|
+
// waitlisted → offered only while its seats still fit: the seat count is in
|
|
1304
|
+
// the same statement, like createBooking's, so two offers (or an offer and a
|
|
1305
|
+
// new RSVP) can't both take the last seats. The email is queued with it.
|
|
1306
|
+
async offerBooking(id: string, holdUntil: number, capacity: number) {
|
|
1307
|
+
await this.ready;
|
|
1308
|
+
const mutation = crypto.randomUUID();
|
|
1309
|
+
await this.driver.batch([
|
|
1310
|
+
{ sql: `UPDATE bookings SET status='offered',hold_until=?,notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=?
|
|
1311
|
+
WHERE id=? AND kind='rsvp' AND status='waitlisted' AND (? = 0 OR (SELECT COALESCE(SUM(t.seats),0) FROM bookings t WHERE t.kind='rsvp' AND t.event_id=bookings.event_id AND t.start_at=bookings.start_at AND t.status IN ${SEATED}) + seats <= ?)`,
|
|
1312
|
+
args: [holdUntil, mutation, Date.now(), id, capacity, capacity] },
|
|
1313
|
+
...bookingJobs(id, 'offered', undefined, false, mutation),
|
|
1314
|
+
]);
|
|
1315
|
+
return !!await this.first('SELECT id FROM bookings WHERE id=? AND email_mutation=?', [id, mutation]);
|
|
1316
|
+
}
|
|
1317
|
+
async expireBooking(id: string, now: number) {
|
|
1318
|
+
await this.ready;
|
|
1319
|
+
const mutation = crypto.randomUUID();
|
|
1320
|
+
await this.driver.run(`UPDATE bookings SET status='expired',notification_version=notification_version+1,email_mutation=?,updated_at=?
|
|
1321
|
+
WHERE id=? AND kind='rsvp' AND status='offered' AND hold_until <= ?`, [mutation, Date.now(), id, now]);
|
|
1322
|
+
return !!await this.first('SELECT id FROM bookings WHERE id=? AND email_mutation=?', [id, mutation]);
|
|
1282
1323
|
}
|
|
1283
1324
|
async deleteBooking(id: string) {
|
|
1284
1325
|
await this.ready;
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { SavedForm, FormSubmission } from '../forms/schema';
|
|
|
7
7
|
import type { LiveStore } from '../live/store';
|
|
8
8
|
import type { BookingRequest } from '../backup/content';
|
|
9
9
|
import type { EmailStore } from '../email/store';
|
|
10
|
+
import type { RsvpStaffNotice } from '../email/types';
|
|
10
11
|
import type { PushStore } from '../push/store';
|
|
11
12
|
import type { AnnouncementStore } from '../announcements/store';
|
|
12
13
|
import type { Announcement } from '../announcements/types';
|
|
@@ -189,21 +190,30 @@ export interface ContentRepository {
|
|
|
189
190
|
// write that can fail on a race: it inserts atomically only if the held
|
|
190
191
|
// range is still free (resource bookings) or seats remain (RSVPs; with
|
|
191
192
|
// oncePerEmail, and the address has no active RSVP for that date), and
|
|
192
|
-
// returns null when it lost.
|
|
193
|
+
// returns null when it lost. staff: who hears about a new RSVP (queued with
|
|
194
|
+
// it, so a lost race or a replay emails no one).
|
|
193
195
|
/** from/to: overlaps the window; endedBy: ended at or before; default order start ascending, at most 5000 rows */
|
|
194
196
|
listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; endedBy?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][]; newestFirst?: boolean; limit?: number; offset?: number }): Promise<Booking[]>;
|
|
195
197
|
getBooking(id: string): Promise<Booking | null>;
|
|
196
198
|
getBookingByToken(token: string): Promise<Booking | null>;
|
|
197
199
|
// bookings + RSVPs (never staff blocks) this address made since `since`
|
|
198
200
|
countBookingsByEmail(email: string, since: number): Promise<number>;
|
|
199
|
-
// locale: the booker's language; '' (the main language) when left out
|
|
201
|
+
// locale: the booker's language; '' (the main language) when left out.
|
|
202
|
+
// A waitlisted RSVP takes no seats, so only oncePerEmail guards it.
|
|
200
203
|
createBooking(
|
|
201
|
-
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale'> & { locale?: string },
|
|
202
|
-
guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string } }
|
|
204
|
+
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale' | 'holdUntil'> & { locale?: string },
|
|
205
|
+
guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string }; staff?: RsvpStaffNotice }
|
|
203
206
|
): Promise<Booking | null>;
|
|
204
207
|
// moves a booking only if the new held range is free (reschedule)
|
|
205
208
|
moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
|
|
206
|
-
|
|
209
|
+
// staff: who hears that the person cancelled their RSVP, or confirmed a spot
|
|
210
|
+
// offered from the waitlist; from: change it only if it still has that status
|
|
211
|
+
updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { from?: BookingStatus; notificationMessage?: string; staff?: RsvpStaffNotice }): Promise<void>;
|
|
212
|
+
// a waitlisted RSVP becomes offered (held until holdUntil, its email queued
|
|
213
|
+
// with it) only if its seats still fit `capacity` (0 = no limit); false when they don't
|
|
214
|
+
offerBooking(id: string, holdUntil: number, capacity: number): Promise<boolean>;
|
|
215
|
+
// an offered RSVP whose hold ended by `now` becomes expired (no email); false when it wasn't
|
|
216
|
+
expireBooking(id: string, now: number): Promise<boolean>;
|
|
207
217
|
deleteBooking(id: string): Promise<void>;
|
|
208
218
|
// confirmed bookings starting in [from, to) that haven't had a reminder
|
|
209
219
|
listDueReminders(from: number, to: number): Promise<Booking[]>;
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { syncDueLive } from './live/service';
|
|
|
2
2
|
import type { ContentRepository } from './repo/types';
|
|
3
3
|
import { processDueDeliveries } from './webhooks';
|
|
4
4
|
import { dispatchEmail } from './email/service';
|
|
5
|
-
import { decideOverdue, sendDueReminders } from './booking/service';
|
|
5
|
+
import { decideOverdue, sendDueReminders, sweepWaitlist } from './booking/service';
|
|
6
6
|
import { runPushJobs } from './push/service';
|
|
7
7
|
import { runAnnouncementJobs } from './announcements/service';
|
|
8
8
|
import { GUEST_IDLE_MS } from './mobile/guest-store';
|
|
@@ -21,6 +21,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
21
21
|
reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
22
22
|
// pending bookings past their approval deadline (Calendar settings → Bookings)
|
|
23
23
|
approvals: () => decideOverdue(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
24
|
+
// held waitlist spots that ran out, and freed seats offered to those in line (before email, so offers go out in this run)
|
|
25
|
+
waitlist: () => sweepWaitlist(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
24
26
|
email: () => dispatchEmail(repo, locals, 10),
|
|
25
27
|
live: () => syncDueLive(repo, locals),
|
|
26
28
|
// scheduled announcements go up (with their push) and ended ones come down
|
package/src/lib/schema.ts
CHANGED
|
@@ -417,7 +417,11 @@ export const CalEvent = z.object({
|
|
|
417
417
|
// RSVP: seats per occurrence (0 = unlimited) and whether RSVPs confirm at
|
|
418
418
|
// once; RSVPs are stored as bookings of kind 'rsvp'. They close at closesAt
|
|
419
419
|
// (wall time in the event's zone, like start; every date), else closeBefore
|
|
420
|
-
// minutes before each date's start, else when the date ends.
|
|
420
|
+
// minutes before each date's start, else when the date ends. notifyStaff:
|
|
421
|
+
// the hosts (else the church's contact email) hear about each new RSVP and
|
|
422
|
+
// each one the person cancels (lib/booking/service.ts rsvpStaff). waitlist:
|
|
423
|
+
// a full date takes places in line, and freed seats are offered in turn
|
|
424
|
+
// (lib/booking/service.ts offerWaitlist); only with a capacity.
|
|
421
425
|
rsvp: z.object({
|
|
422
426
|
enabled: z.boolean().default(false),
|
|
423
427
|
capacity: z.number().int().default(0),
|
|
@@ -425,6 +429,8 @@ export const CalEvent = z.object({
|
|
|
425
429
|
maxSeats: z.number().int().default(4),
|
|
426
430
|
closesAt: z.string().nullable().default(null),
|
|
427
431
|
closeBefore: z.number().int().default(0),
|
|
432
|
+
notifyStaff: z.boolean().default(true),
|
|
433
|
+
waitlist: z.boolean().default(false),
|
|
428
434
|
form: FormDefinition.optional(),
|
|
429
435
|
}).default({}),
|
|
430
436
|
// rooms/people this event uses: they can't be booked while it happens (or
|
|
@@ -650,7 +656,10 @@ export const BookingLink = z.object({
|
|
|
650
656
|
});
|
|
651
657
|
export type BookingLink = z.infer<typeof BookingLink>;
|
|
652
658
|
|
|
653
|
-
|
|
659
|
+
// RSVPs only: waitlisted = in line for a full date (takes no seats); offered =
|
|
660
|
+
// a freed spot held for them until holdUntil (takes its seats); expired = they
|
|
661
|
+
// didn't confirm it in time
|
|
662
|
+
export const BookingStatus = z.enum(['pending', 'confirmed', 'declined', 'cancelled', 'waitlisted', 'offered', 'expired']);
|
|
654
663
|
export type BookingStatus = z.infer<typeof BookingStatus>;
|
|
655
664
|
|
|
656
665
|
export const Booking = z.object({
|
|
@@ -682,6 +691,8 @@ export const Booking = z.object({
|
|
|
682
691
|
// emails and links speak it. '' = the site's main language, as on page rows
|
|
683
692
|
locale: z.string().default(''),
|
|
684
693
|
reminderSentAt: z.number().nullable().default(null),
|
|
694
|
+
// an offered spot is theirs until then (ms)
|
|
695
|
+
holdUntil: z.number().nullable().default(null),
|
|
685
696
|
notificationVersion: z.number().default(0),
|
|
686
697
|
createdAt: z.number(),
|
|
687
698
|
updatedAt: z.number(),
|