@acms-subir/core 0.3.39 → 0.3.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/integration.mjs +1 -0
- package/package.json +1 -1
- package/src/actions/index.ts +1 -1
- package/src/components/editor/EventPanel.tsx +3 -2
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +4 -0
- package/src/components/editor/event/registration.ts +1 -1
- package/src/lib/booking/emails.ts +36 -12
- package/src/lib/booking/service.ts +64 -14
- 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/content-service.ts +7 -4
- package/src/lib/drafts.ts +2 -2
- package/src/lib/editor/documents.ts +3 -2
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +56 -0
- package/src/lib/email/service.ts +1 -1
- package/src/lib/email/store.ts +17 -1
- package/src/lib/email/types.ts +3 -1
- package/src/lib/legal/text-en.ts +1 -1
- package/src/lib/legal/text-es.ts +1 -1
- package/src/lib/mobile/calendar.ts +23 -1
- package/src/lib/mobile/manage.ts +1 -1
- package/src/lib/mobile/openapi.ts +2 -1
- package/src/lib/mobile/schemas.ts +2 -1
- package/src/lib/repo/sql.ts +4 -3
- package/src/lib/repo/types.ts +6 -3
- package/src/lib/schema.ts +4 -1
- package/src/pages/api/v1/events/[slug]/index.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.40
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b71f8c3: New `GET /api/v1/events/:slug` (or the event's id) returns one public event and its next date, or the date on `?on=YYYY-MM-DD` as the website's links use, however far ahead it is. Apps opening an event from a link, a push or an announcement no longer get "Not found" for events more than 30 days out. Drafts and events in private calendars answer 404.
|
|
8
|
+
- 7fc0c3f: Each new RSVP, and each RSVP the person cancels with its link or in the app, now emails the event's hosts. Events without hosts email the contact address in Settings → Legal, or the owners when there isn't one. The email is in the site's main language: "New RSVP: Youth retreat, Fri Nov 7", then the person's name, email and phone, the date, the seats and status ("2 · Confirmed", or "2 · Waiting for your approval" when RSVPs need approving), their answers, how many seats are taken now ("30 of 40 seats taken"), a button to the RSVP in Bookings (or, for a host who can't manage bookings, such as a member who hosts a class, "Open in My events", their page for the event), and whether it came from the website, the app or staff. A cancellation says "RSVP cancelled: …" and how many seats it freed. Replying writes to the person. Staff cancelling, approving or declining an RSVP, a cancelled date or event, a second RSVP from the same email and app retries send no staff email. Each event has **Email the hosts** in its CTA card, on for every event including existing ones; **Staff notifications** in Email settings still turns off all staff emails. Editor documents carry the setting as `rsvp.notifyStaff`; the public event JSON and the app API leave it out. The privacy notice now says the event's hosts (or staff) get an email with RSVP details, so its "Last updated" date moves.
|
|
9
|
+
|
|
3
10
|
## 0.3.39
|
|
4
11
|
|
|
5
12
|
### Patch Changes
|
package/integration.mjs
CHANGED
|
@@ -835,6 +835,7 @@ export default function acms(options = {}) {
|
|
|
835
835
|
['/api/v1/events', 'api/v1/events.ts'],
|
|
836
836
|
['/api/v1/calendars', 'api/v1/calendars.ts'],
|
|
837
837
|
// RSVPs from apps: a date's status, then POST /bookings (room bookings there need bookings on)
|
|
838
|
+
['/api/v1/events/[slug]', 'api/v1/events/[slug]/index.ts'],
|
|
838
839
|
['/api/v1/events/[slug]/rsvp', 'api/v1/events/[slug]/rsvp.ts'],
|
|
839
840
|
['/api/v1/bookings', 'api/v1/bookings.ts'],
|
|
840
841
|
['/api/calendar/events', 'api/calendar/events/index.ts'],
|
package/package.json
CHANGED
package/src/actions/index.ts
CHANGED
|
@@ -995,7 +995,7 @@ export const server = {
|
|
|
995
995
|
calendarIds: z.array(z.string()).max(50).optional(),
|
|
996
996
|
fields: z.record(z.string().max(2000)).optional(),
|
|
997
997
|
rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50),
|
|
998
|
-
closesAt: z.string().max(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), form: z.any().optional() }).optional(),
|
|
998
|
+
closesAt: z.string().max(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), notifyStaff: z.boolean().optional(), form: z.any().optional() }).optional(),
|
|
999
999
|
roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }).optional(),
|
|
1000
1000
|
scheduledAt: nullableTs.optional(),
|
|
1001
1001
|
}),
|
|
@@ -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
|
|
25
|
+
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: 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 },
|
|
173
173
|
roomHold: NO_HOLD,
|
|
174
174
|
};
|
|
175
175
|
|
|
@@ -73,6 +73,10 @@ 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>
|
|
76
80
|
</div>
|
|
77
81
|
<Deadline meta={meta} onChange={onChange} />
|
|
78
82
|
<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 };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function registrationMode({ cta, rsvp }: RegistrationData): RegistrationMode {
|
|
@@ -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
|
|
|
@@ -75,30 +76,36 @@ interface Ctx {
|
|
|
75
76
|
allDay?: boolean;
|
|
76
77
|
/** the words' language: the booking's for guest emails, the main one for staff */
|
|
77
78
|
lang?: string;
|
|
79
|
+
/** staff RSVP emails: the date's short form, seats taken right after, why they get it and where the person did it */
|
|
80
|
+
rsvpStaff?: { date: string; taken: number; capacity: number; why: RsvpStaffNotice['why']; via: Booking['source'] };
|
|
81
|
+
/** manageUrl is the recipient's My events page (a host without Manage bookings) */
|
|
82
|
+
mineLink?: boolean;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
// own: a staff email's own rows and footer instead of the details and reference
|
|
86
|
+
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 } {
|
|
87
|
+
const rows: [string, string][] = own?.rows ?? [[w.what, c.title], [w.when, c.when], ...(c.location ? [[w.where, c.location] as [string, string]] : [])];
|
|
88
|
+
if (!own && c.booking.seats > 1) rows.push([w.seats, String(c.booking.seats)]);
|
|
89
|
+
const footer = own?.footer ?? fill(w.reference, { ref: c.booking.id.slice(0, 8).toUpperCase() });
|
|
84
90
|
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
91
|
<div style="max-width:520px;margin:0 auto;padding:28px 20px">
|
|
86
92
|
<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
|
-
|
|
93
|
+
<h1 style="font-size:22px;margin:0 0 ${intro ? 8 : 18}px">${esc(heading)}</h1>
|
|
94
|
+
${intro ? `<p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>` : ''}
|
|
89
95
|
<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>`)
|
|
96
|
+
.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
97
|
.join('')}</table>
|
|
92
98
|
${c.message ? `<p style="font-size:14px;line-height:1.55;margin:18px 0 0">${esc(c.message)}</p>` : ''}
|
|
93
99
|
${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(
|
|
100
|
+
<p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(footer)}</p>
|
|
95
101
|
</div></body></html>`;
|
|
96
|
-
const text = [heading, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '',
|
|
102
|
+
const text = [heading, '', ...(intro ? [intro, ''] : []), ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '', footer].join('\n');
|
|
97
103
|
return { html, text };
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
// link: the booking's own link again, after someone tried to make it twice
|
|
101
|
-
|
|
106
|
+
// link: the booking's own link again, after someone tried to make it twice.
|
|
107
|
+
// staff-rsvp(-cancelled): the hosts hear about a new RSVP, or one the person cancelled
|
|
108
|
+
export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'link' | 'staff-new' | 'staff-rsvp' | 'staff-rsvp-cancelled';
|
|
102
109
|
|
|
103
110
|
export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
104
111
|
const w = bookingStrings(c.lang);
|
|
@@ -106,7 +113,7 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
106
113
|
const manage = { href: c.manageUrl, label: rsvp ? w.rsvp.manage : w.manage };
|
|
107
114
|
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
115
|
// null: no button (nothing left to change)
|
|
109
|
-
const guest = (k: Exclude<BookingEmail,
|
|
116
|
+
const guest = (k: Exclude<BookingEmail, `staff-${string}`>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
|
|
110
117
|
const copy = mailCopy(w, k, rsvp);
|
|
111
118
|
const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when };
|
|
112
119
|
return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(copy.heading, fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
|
|
@@ -128,5 +135,22 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
128
135
|
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
136
|
return { to: [], subject: fill(pending ? w.staff.pendingSubject : w.staff.bookedSubject, vars), ...m, replyTo: c.booking.email || undefined };
|
|
130
137
|
}
|
|
138
|
+
case 'staff-rsvp':
|
|
139
|
+
case 'staff-rsvp-cancelled': {
|
|
140
|
+
const r = w.rsvpStaff, s = c.rsvpStaff!, b = c.booking;
|
|
141
|
+
const cancelled = kind === 'staff-rsvp-cancelled', pending = !cancelled && b.status === 'pending';
|
|
142
|
+
const seats = String(b.seats), count = String(s.taken);
|
|
143
|
+
const answers = Object.entries(b.answers).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
144
|
+
const rows: [string, string][] = [
|
|
145
|
+
[r.name, b.name], [r.email, b.email], ...(b.phone ? [[r.phone, b.phone] as [string, string]] : []), [w.when, c.when],
|
|
146
|
+
[w.seats, fill(cancelled ? (b.seats === 1 ? r.seatFreed : r.seatsFreed) : pending ? r.pending : r.confirmed, { seats })],
|
|
147
|
+
...(answers ? [[r.answers, answers] as [string, string]] : []),
|
|
148
|
+
[r.now, s.capacity ? fill(r.seatsTakenOf, { count, capacity: String(s.capacity) }) : fill(s.taken === 1 ? r.seatTaken : r.seatsTaken, { count })],
|
|
149
|
+
];
|
|
150
|
+
const vars = { title: c.title, date: s.date };
|
|
151
|
+
const footer = `${r.via[s.via === 'web' || s.via === 'app' ? s.via : 'staff']} · ${s.why === 'hosts' ? r.hosts : r.noHosts}`;
|
|
152
|
+
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 });
|
|
153
|
+
return { to: [], subject: fill(cancelled ? r.cancelledSubject : r.subject, vars), ...m, replyTo: b.email || undefined };
|
|
154
|
+
}
|
|
131
155
|
}
|
|
132
156
|
}
|
|
@@ -9,6 +9,10 @@ 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';
|
|
@@ -18,6 +22,8 @@ import { bookingEmail, bookingLanguage, bookingManagePath, whenText, type Bookin
|
|
|
18
22
|
import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
|
|
19
23
|
import { decideStatus, linkApproval } from './approval';
|
|
20
24
|
import { eventHolds } from './holds';
|
|
25
|
+
import { MEMBERS, memberEventsPath } from '../calendar/members-config';
|
|
26
|
+
import { principalCan } from '../permissions/access';
|
|
21
27
|
|
|
22
28
|
export interface FlowOptions {
|
|
23
29
|
origin: string;
|
|
@@ -88,7 +94,7 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
|
|
|
88
94
|
const ev = raw && locale ? localizeDoc(raw, locale) : raw;
|
|
89
95
|
const tz = ev ? eventTimeZone(ev, settings) : settings.timezone || 'UTC';
|
|
90
96
|
return {
|
|
91
|
-
resource: null, link: null, event: ev,
|
|
97
|
+
resource: null, link: null, event: ev, tz,
|
|
92
98
|
title: ev?.title ?? 'Event',
|
|
93
99
|
location: ev ? locationLine(ev.location) : '',
|
|
94
100
|
when: whenText(b, tz, settings, ev?.allDay),
|
|
@@ -97,7 +103,7 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
|
|
|
97
103
|
const [resource, link] = await Promise.all([repo.getResource(b.resourceId), b.linkId ? repo.getBookingLink(b.linkId) : Promise.resolve(null)]);
|
|
98
104
|
const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
|
|
99
105
|
return {
|
|
100
|
-
resource, link, event: null as CalEvent | null,
|
|
106
|
+
resource, link, event: null as CalEvent | null, tz,
|
|
101
107
|
title: [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
|
|
102
108
|
location: resource?.name ?? '',
|
|
103
109
|
when: whenText(b, tz, settings, link?.mode === 'day'),
|
|
@@ -114,11 +120,17 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
|
|
|
114
120
|
};
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
//
|
|
118
|
-
|
|
123
|
+
// the booking in the admin; an RSVP among its event's (a cancelled one under Cancelled)
|
|
124
|
+
const adminBookingUrl = (origin: string, b: Booking, kind: BookingEmail) =>
|
|
125
|
+
`${origin}/admin/calendar/bookings?${b.eventId ? `event=${encodeURIComponent(b.eventId)}&` : ''}${kind === 'staff-rsvp-cancelled' ? 'tab=cancelled&' : ''}b=${b.id}`;
|
|
126
|
+
|
|
127
|
+
// snapshot.locale: the language to use instead of the booking's (a link sent
|
|
128
|
+
// again); taken/why/via: a staff RSVP email's job (rsvpStaffJobs)
|
|
129
|
+
export async function bookingMail(repo: ContentRepository, b: Booking, kind: BookingEmail, origin: string, from = '', snapshot?: { message?: string; eventTitle?: string; eventLocation?: string; eventAllDay?: number; locale?: string; taken?: number; why?: RsvpStaffNotice['why']; via?: Booking['source'] }, recipient = '') {
|
|
119
130
|
const site = await repo.getSettings();
|
|
131
|
+
const staff = kind.startsWith('staff-');
|
|
120
132
|
// the guest hears in the language they booked in; staff in the main one
|
|
121
|
-
const language = bookingLanguage(
|
|
133
|
+
const language = bookingLanguage(staff ? { locale: '' } : typeof snapshot?.locale === 'string' ? { locale: snapshot.locale } : b, site);
|
|
122
134
|
const settings = language.settings;
|
|
123
135
|
const d = await describe(repo,b,settings,language.locale);
|
|
124
136
|
if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
|
|
@@ -126,10 +138,42 @@ export async function bookingMail(repo: ContentRepository, b: Booking, kind: Boo
|
|
|
126
138
|
try { d.location = locationLine(JSON.parse(snapshot.eventLocation || '{}')); } catch { d.location = ''; }
|
|
127
139
|
d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
|
|
128
140
|
}
|
|
141
|
+
// "Fri Nov 7": the subject's date, in the event's zone
|
|
142
|
+
const counts = kind === 'staff-rsvp' || kind === 'staff-rsvp-cancelled' ? {
|
|
143
|
+
date: new Intl.DateTimeFormat(siteLocale(settings), { weekday: 'short', month: 'short', day: 'numeric', timeZone: d.tz }).format(b.start).replace(/,/g, ''),
|
|
144
|
+
taken: snapshot?.taken ?? await seatsTaken(repo, b.eventId, b.start), capacity: d.event?.rsvp.capacity ?? 0,
|
|
145
|
+
why: snapshot?.why ?? 'hosts', via: snapshot?.via ?? b.source,
|
|
146
|
+
} : undefined;
|
|
147
|
+
// a host without Manage bookings (a member who hosts) gets their My events
|
|
148
|
+
// page for the event instead of an admin page they can't open
|
|
149
|
+
const mine = counts && d.event && recipient ? await memberHostPath(repo, d.event, recipient) : '';
|
|
129
150
|
return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
|
|
130
|
-
manageUrl:
|
|
151
|
+
manageUrl:mine ? origin + mine : staff ? adminBookingUrl(origin, b, kind) : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
|
|
152
|
+
mineLink: !!mine,
|
|
131
153
|
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 });
|
|
154
|
+
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang, rsvpStaff: counts });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The member's My events page for an event they host, when they can't manage bookings in the admin ('' otherwise). */
|
|
158
|
+
async function memberHostPath(repo: ContentRepository, e: CalEvent, email: string): Promise<string> {
|
|
159
|
+
const user = (await repo.listUsers()).find((u) => u.email && u.email.trim().toLowerCase() === email.trim().toLowerCase());
|
|
160
|
+
if (!user || !MEMBERS.enabled || !e.hostIds.includes(user.id)) return '';
|
|
161
|
+
return (await principalCan(repo, { user }, 'schedule.bookings')) ? '' : `${memberEventsPath()}/${encodeURIComponent(e.id)}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Who hears about an event's RSVPs (rsvp.notifyStaff): its hosts, else the
|
|
166
|
+
* contact email in Settings → Legal (then acms({ legal }), as legalOperator
|
|
167
|
+
* reads it), else the owners. Accounts that were removed, or whose role the
|
|
168
|
+
* site no longer has, are skipped. via: where the person did it.
|
|
169
|
+
*/
|
|
170
|
+
export async function rsvpStaff(repo: ContentRepository, e: CalEvent, settings: Settings, via: Booking['source']): Promise<RsvpStaffNotice | undefined> {
|
|
171
|
+
if (!e.rsvp.notifyStaff) return undefined;
|
|
172
|
+
const contact = settings.legal.contactEmail || LEGAL.contactEmail;
|
|
173
|
+
const users = e.hostIds.length || !contact ? (await repo.listUsers()).filter((u) => u.email && isRole(u.role)) : [];
|
|
174
|
+
const hosts = users.filter((u) => e.hostIds.includes(u.id)).map((u) => u.email);
|
|
175
|
+
const to = hosts.length ? hosts : contact ? [contact] : users.filter((u) => u.role === 'owner').map((u) => u.email);
|
|
176
|
+
return { to: [...new Set(to.map((a) => a.trim().toLowerCase()))].filter(Boolean), why: hosts.length ? 'hosts' : 'contact', via };
|
|
133
177
|
}
|
|
134
178
|
async function notify(repo: ContentRepository, _b: Booking, _kinds: BookingEmail[], opts: FlowOptions, _message?: string) {
|
|
135
179
|
// Repository mutations already committed their outbox jobs atomically.
|
|
@@ -205,12 +249,15 @@ export function guestChanges(b: Booking, link: BookingLink | null, now = Date.no
|
|
|
205
249
|
};
|
|
206
250
|
}
|
|
207
251
|
|
|
208
|
-
|
|
252
|
+
// via: where they cancelled (the manage page, or an app)
|
|
253
|
+
export async function guestCancel(repo: ContentRepository, b: Booking, opts: FlowOptions, via: Booking['source'] = 'web'): Promise<Booking> {
|
|
209
254
|
const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
|
|
210
255
|
if (b.status === 'cancelled') return b;
|
|
211
256
|
const can = guestChanges(b, link, opts.now);
|
|
212
257
|
if (!can.cancel) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to cancel.` : 'This booking can’t be cancelled.');
|
|
213
|
-
|
|
258
|
+
// the person cancelled an RSVP: its hosts hear about it (staff cancelling never emails staff)
|
|
259
|
+
const event = b.kind === 'rsvp' && b.eventId ? await repo.getEvent(b.eventId) : null;
|
|
260
|
+
return setBookingStatus(repo, b, 'cancelled', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
|
|
214
261
|
}
|
|
215
262
|
|
|
216
263
|
/** the link as a guest moving a booking sees it: no new time inside the change cutoff */
|
|
@@ -294,11 +341,13 @@ export async function createRsvp(
|
|
|
294
341
|
if (formResponse) booker.answers = displayAnswers(formResponse);
|
|
295
342
|
// one RSVP per address and date on public channels; staff may add more
|
|
296
343
|
const once = args.source !== 'admin';
|
|
344
|
+
const source = args.source ?? 'web';
|
|
345
|
+
// the hosts' email is queued with the RSVP: a duplicate or a replay inserts nothing, so it emails no one
|
|
297
346
|
const b = await repo.createBooking({
|
|
298
347
|
kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
|
|
299
348
|
start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
|
|
300
|
-
formResponse, notes: '', manageToken: token(), source
|
|
301
|
-
}, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency });
|
|
349
|
+
formResponse, notes: '', manageToken: token(), source, locale: normalizePageLocale(args.booker.locale ?? ''),
|
|
350
|
+
}, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency, staff: await rsvpStaff(repo, e, settings, source) });
|
|
302
351
|
if (!b) {
|
|
303
352
|
// a retry of a request that just committed: the caller replays that one
|
|
304
353
|
const retry = opts.idempotency ? await repo.getBookingRequest(opts.idempotency.keyHash) : null;
|
|
@@ -310,7 +359,7 @@ export async function createRsvp(
|
|
|
310
359
|
}
|
|
311
360
|
throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
|
|
312
361
|
}
|
|
313
|
-
await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
|
|
362
|
+
await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received', 'staff-rsvp'], opts);
|
|
314
363
|
await hook(repo, 'booking.created', b, opts);
|
|
315
364
|
if (b.status === 'confirmed') await hook(repo, 'booking.confirmed', b, opts);
|
|
316
365
|
return b;
|
|
@@ -332,7 +381,8 @@ const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
|
|
|
332
381
|
cancelled: ['confirmed'],
|
|
333
382
|
};
|
|
334
383
|
|
|
335
|
-
|
|
384
|
+
// staff: who to tell that the person cancelled (guestCancel); staff changes tell no one
|
|
385
|
+
export async function setBookingStatus(repo: ContentRepository, b: Booking, status: BookingStatus, opts: FlowOptions, message?: string, staff?: RsvpStaffNotice): Promise<Booking> {
|
|
336
386
|
if (b.status === status) return b;
|
|
337
387
|
if (!TRANSITIONS[b.status].includes(status)) throw new BookingError(409, `A ${b.status} booking can’t become ${status}.`);
|
|
338
388
|
// re-confirming a declined/cancelled booking must re-check the slot
|
|
@@ -341,7 +391,7 @@ export async function setBookingStatus(repo: ContentRepository, b: Booking, stat
|
|
|
341
391
|
.some((x) => x.id !== b.id && x.kind !== 'rsvp' && x.holdStart < b.holdEnd && x.holdEnd > b.holdStart);
|
|
342
392
|
if (clash) throw new BookingError(409, 'That time has been booked since — it can’t be restored.');
|
|
343
393
|
}
|
|
344
|
-
await repo.updateBooking(b.id, { status, notificationMessage: message });
|
|
394
|
+
await repo.updateBooking(b.id, { status, notificationMessage: message, staff });
|
|
345
395
|
const next = (await repo.getBooking(b.id))!;
|
|
346
396
|
if (!next || next.status !== status) throw new BookingError(409, 'This booking changed. Refresh and try again.');
|
|
347
397
|
if (b.kind !== 'block') {
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -27,7 +27,8 @@ export interface EventJson {
|
|
|
27
27
|
cancelledDates: string[];
|
|
28
28
|
location: CalEvent['location'];
|
|
29
29
|
cta: CalEvent['cta'];
|
|
30
|
-
|
|
30
|
+
// notifyStaff (whether the hosts get RSVP emails) only for staff callers
|
|
31
|
+
rsvp?: Omit<CalEvent['rsvp'], 'notifyStaff'> & Partial<Pick<CalEvent['rsvp'], 'notifyStaff'>>;
|
|
31
32
|
image: string;
|
|
32
33
|
imageAlt: string;
|
|
33
34
|
tags: string[];
|
|
@@ -59,7 +60,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
59
60
|
cancelledDates: e.cancelledDates ?? [],
|
|
60
61
|
location: e.location,
|
|
61
62
|
cta: e.cta,
|
|
62
|
-
rsvp: e.rsvp,
|
|
63
|
+
rsvp: opts.includePrivate ? e.rsvp : (({ notifyStaff: _, ...rsvp }) => rsvp)(e.rsvp),
|
|
63
64
|
image: e.cover ? abs(origin, e.cover) : '',
|
|
64
65
|
imageAlt: e.coverAlt,
|
|
65
66
|
tags: e.tags.filter((t) => !t.startsWith('#')),
|
|
@@ -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 }
|
|
@@ -37,10 +37,10 @@ export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blo
|
|
|
37
37
|
// location (see mergeLocationInput) — older clients send name/address/url only
|
|
38
38
|
// cancelledDates is left out: single dates are cancelled at once through
|
|
39
39
|
// cancelOccurrence/restoreOccurrence (lib/calendar/schedule.ts), never staged
|
|
40
|
-
// rsvp may leave out the deadline (older clients): the current
|
|
40
|
+
// rsvp may leave out the deadline and notifyStaff (older clients): the current ones stay
|
|
41
41
|
export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location' | 'cancelledDates' | 'rsvp'>> & {
|
|
42
42
|
location?: Partial<EventLocation>;
|
|
43
|
-
rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore'>>;
|
|
43
|
+
rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff'>>;
|
|
44
44
|
};
|
|
45
45
|
type EventClean = Omit<EventSave, 'location' | 'rsvp'> & { location?: EventLocation; rsvp?: CalEvent['rsvp'] };
|
|
46
46
|
|
|
@@ -136,9 +136,11 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
|
|
|
136
136
|
const out: EventClean = { ...rest };
|
|
137
137
|
if (rsvp) {
|
|
138
138
|
// Older editor clients resend the original RSVP shape. Omission must not
|
|
139
|
-
// erase questions
|
|
139
|
+
// erase questions, a deadline or the staff emails setting that were
|
|
140
|
+
// authored in a newer editor.
|
|
140
141
|
const previous = mergeEventDraft(before).rsvp;
|
|
141
|
-
const next: CalEvent['rsvp'] = { ...rsvp, closesAt: rsvp.closesAt === undefined ? previous.closesAt ?? null : rsvp.closesAt || null, closeBefore: rsvp.closeBefore ?? previous.closeBefore ?? 0
|
|
142
|
+
const next: CalEvent['rsvp'] = { ...rsvp, closesAt: rsvp.closesAt === undefined ? previous.closesAt ?? null : rsvp.closesAt || null, closeBefore: rsvp.closeBefore ?? previous.closeBefore ?? 0,
|
|
143
|
+
notifyStaff: rsvp.notifyStaff ?? previous.notifyStaff ?? true };
|
|
142
144
|
if (!Object.hasOwn(rsvp, 'form') && previous.form) next.form = previous.form;
|
|
143
145
|
if (next.form !== undefined) {
|
|
144
146
|
const form = FormDefinition.safeParse(next.form);
|
|
@@ -153,6 +155,7 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
|
|
|
153
155
|
if (!Number.isInteger(next.closeBefore) || next.closeBefore < 0 || next.closeBefore > 60 * 1440) {
|
|
154
156
|
throw new ApiError(400, 'The RSVP deadline can be at most 60 days before each date (closeBefore: 0–86400 minutes).');
|
|
155
157
|
}
|
|
158
|
+
if (typeof next.notifyStaff !== 'boolean') throw new ApiError(400, 'rsvp.notifyStaff must be true or false.');
|
|
156
159
|
out.rsvp = next;
|
|
157
160
|
}
|
|
158
161
|
if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
|
package/src/lib/drafts.ts
CHANGED
|
@@ -52,8 +52,8 @@ export function pickEventDraft(draft: Record<string, unknown>): Partial<Pick<Cal
|
|
|
52
52
|
const loc = EventLocation.safeParse(out.location);
|
|
53
53
|
if (loc.success) out.location = loc.data; else delete out.location;
|
|
54
54
|
}
|
|
55
|
-
// and before RSVP deadlines: none
|
|
56
|
-
if (out.rsvp) out.rsvp = { ...out.rsvp, closesAt: out.rsvp.closesAt ?? null, closeBefore: out.rsvp.closeBefore ?? 0 };
|
|
55
|
+
// and before RSVP deadlines: none; before staff RSVP emails: on
|
|
56
|
+
if (out.rsvp) out.rsvp = { ...out.rsvp, closesAt: out.rsvp.closesAt ?? null, closeBefore: out.rsvp.closeBefore ?? 0, notifyStaff: out.rsvp.notifyStaff ?? true };
|
|
57
57
|
return out;
|
|
58
58
|
}
|
|
59
59
|
export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
|
|
@@ -160,9 +160,10 @@ export const EventFields = z.object({ ...common, excerpt: text(1000), tags: z.ar
|
|
|
160
160
|
}).partial({ lat: true, lng: true, placeId: true, showMap: true, mapImage: true, mapImageDark: true }).strict(),
|
|
161
161
|
cta: z.object({ label: text(120), href: text(1000), style: z.enum(['primary', 'outline']) }).strict(),
|
|
162
162
|
calendarIds: z.array(text(100)).max(50), fields: z.record(text(2000)),
|
|
163
|
-
// closesAt: wall time in the event's zone ('YYYY-MM-DDTHH:mm'); closeBefore: minutes before each date
|
|
163
|
+
// closesAt: wall time in the event's zone ('YYYY-MM-DDTHH:mm'); closeBefore: minutes before each date;
|
|
164
|
+
// notifyStaff: the hosts get an email about each new or cancelled RSVP. Left out = kept
|
|
164
165
|
rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50),
|
|
165
|
-
closesAt: text(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), form: FormDefinition.optional() }).strict(),
|
|
166
|
+
closesAt: text(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), notifyStaff: z.boolean().optional(), form: FormDefinition.optional() }).strict(),
|
|
166
167
|
}).partial().strict();
|
|
167
168
|
export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
|
|
168
169
|
|
|
@@ -47,7 +47,7 @@ export const EditorDocument = EditorDocumentSummary.extend({
|
|
|
47
47
|
event: z.object({
|
|
48
48
|
start: str, end: str, allDay: bool, timezone: str, rrule: str, exdates: z.array(str), cancelled: bool,
|
|
49
49
|
location: z.object({ name: str, address: str, url: str, lat: num.nullable(), lng: num.nullable(), placeId: str, showMap: bool, mapImage: str, mapImageDark: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
|
|
50
|
-
calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, closesAt: str.nullable(), closeBefore: num, form: FormDefinition.optional() }),
|
|
50
|
+
calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, closesAt: str.nullable(), closeBefore: num, notifyStaff: bool, form: FormDefinition.optional() }),
|
|
51
51
|
}).nullable(),
|
|
52
52
|
languages: z.array(str),
|
|
53
53
|
});
|
|
@@ -25,6 +25,22 @@ export interface BookingStrings {
|
|
|
25
25
|
/** a pending booking nobody answered in time, declined automatically */
|
|
26
26
|
lateDecline: string;
|
|
27
27
|
staff: { pending: string; booked: string; review: string; open: string; pendingSubject: string; bookedSubject: string };
|
|
28
|
+
/**
|
|
29
|
+
* the hosts' email about an RSVP (or the contact address's, for an event
|
|
30
|
+
* without hosts): a new one, or one the person cancelled. {seats}, {count}
|
|
31
|
+
* and {capacity} are numbers, {date} the date's short form ("Fri Nov 7");
|
|
32
|
+
* the footer is `via` · `hosts` or `noHosts`.
|
|
33
|
+
*/
|
|
34
|
+
rsvpStaff: {
|
|
35
|
+
subject: string; cancelledSubject: string; heading: string; cancelledHeading: string;
|
|
36
|
+
name: string; email: string; phone: string; answers: string; now: string;
|
|
37
|
+
confirmed: string; pending: string; seatFreed: string; seatsFreed: string;
|
|
38
|
+
seatTaken: string; seatsTaken: string; seatsTakenOf: string;
|
|
39
|
+
via: Record<'web' | 'app' | 'staff', string>;
|
|
40
|
+
hosts: string; noHosts: string;
|
|
41
|
+
/** the button for a host without Manage bookings: their My events page */
|
|
42
|
+
openMine: string;
|
|
43
|
+
};
|
|
28
44
|
/** a room booking's own page: what differs from an RSVP's (rsvp-words.ts) */
|
|
29
45
|
page: {
|
|
30
46
|
untitled: string;
|
|
@@ -60,6 +76,14 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
60
76
|
what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
|
|
61
77
|
lateDecline: 'We weren’t able to confirm this booking in time. Please pick another time.',
|
|
62
78
|
staff: { pending: 'New request to approve', booked: 'New booking', review: 'Review in the admin', open: 'Open in the admin', pendingSubject: 'Approve: {title} — {when}', bookedSubject: 'New booking: {title} — {when}' },
|
|
79
|
+
rsvpStaff: {
|
|
80
|
+
subject: 'New RSVP: {title}, {date}', cancelledSubject: 'RSVP cancelled: {title}, {date}', heading: 'New RSVP for {title}', cancelledHeading: 'RSVP cancelled for {title}',
|
|
81
|
+
name: 'Name', email: 'Email', phone: 'Phone', answers: 'Answers', now: 'Now',
|
|
82
|
+
confirmed: '{seats} · Confirmed', pending: '{seats} · Waiting for your approval', seatFreed: '{seats} freed', seatsFreed: '{seats} freed',
|
|
83
|
+
seatTaken: '{count} seat taken', seatsTaken: '{count} seats taken', seatsTakenOf: '{count} of {capacity} seats taken',
|
|
84
|
+
via: { web: 'Sent from the website', app: 'Sent from the app', staff: 'Added by staff' }, openMine: 'Open in My events',
|
|
85
|
+
hosts: 'You get these because you host this event.', noHosts: 'You get these because no one hosts this event.',
|
|
86
|
+
},
|
|
63
87
|
page: {
|
|
64
88
|
untitled: 'Booking', booked: 'You’re booked', yours: 'Your booking', moved: 'Booking moved', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
|
|
65
89
|
declined: 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.', cancelledText: 'This booking is cancelled.',
|
|
@@ -93,6 +117,14 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
93
117
|
what: 'Qué', when: 'Cuándo', where: 'Dónde', seats: 'Lugares', reference: 'Referencia {ref}', icsManage: 'Ver o cambiar: {url}',
|
|
94
118
|
lateDecline: 'No pudimos confirmar esta reservación a tiempo. Por favor, elija otro horario.',
|
|
95
119
|
staff: { pending: 'Nueva solicitud por aprobar', booked: 'Nueva reservación', review: 'Revisar en el panel', open: 'Abrir en el panel', pendingSubject: 'Por aprobar: {title} — {when}', bookedSubject: 'Nueva reservación: {title} — {when}' },
|
|
120
|
+
rsvpStaff: {
|
|
121
|
+
subject: 'Nueva confirmación: {title}, {date}', cancelledSubject: 'Confirmación cancelada: {title}, {date}', heading: 'Nueva confirmación para {title}', cancelledHeading: 'Confirmación cancelada para {title}',
|
|
122
|
+
name: 'Nombre', email: 'Correo', phone: 'Teléfono', answers: 'Respuestas', now: 'Ahora',
|
|
123
|
+
confirmed: '{seats} · Confirmado', pending: '{seats} · Esperando su aprobación', seatFreed: '{seats} liberado', seatsFreed: '{seats} liberados',
|
|
124
|
+
seatTaken: '{count} lugar ocupado', seatsTaken: '{count} lugares ocupados', seatsTakenOf: '{count} de {capacity} lugares ocupados',
|
|
125
|
+
via: { web: 'Enviado desde el sitio web', app: 'Enviado desde la app', staff: 'Agregado por el equipo' }, openMine: 'Abrir en Mis eventos',
|
|
126
|
+
hosts: 'Recibe estos correos porque usted organiza este evento.', noHosts: 'Recibe estos correos porque este evento no tiene organizadores.',
|
|
127
|
+
},
|
|
96
128
|
page: {
|
|
97
129
|
untitled: 'Reservación', booked: 'Tu reservación está confirmada', yours: 'Tu reservación', moved: 'Tu reservación cambió de horario', notAvailable: 'No disponible', cancelledTitle: 'Reservación cancelada',
|
|
98
130
|
declined: 'Lo sentimos, no se pudo confirmar este horario. Puedes elegir otro.', cancelledText: 'Esta reservación se canceló.',
|
|
@@ -126,6 +158,14 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
126
158
|
what: 'Quoi', when: 'Quand', where: 'Où', seats: 'Places', reference: 'Référence {ref}', icsManage: 'Gérer : {url}',
|
|
127
159
|
lateDecline: 'Nous n’avons pas pu confirmer cette réservation à temps. Veuillez choisir un autre horaire.',
|
|
128
160
|
staff: { pending: 'Nouvelle demande à valider', booked: 'Nouvelle réservation', review: 'Examiner dans l’administration', open: 'Ouvrir dans l’administration', pendingSubject: 'À valider : {title} — {when}', bookedSubject: 'Nouvelle réservation : {title} — {when}' },
|
|
161
|
+
rsvpStaff: {
|
|
162
|
+
subject: 'Nouvelle inscription : {title}, {date}', cancelledSubject: 'Inscription annulée : {title}, {date}', heading: 'Nouvelle inscription à {title}', cancelledHeading: 'Inscription à {title} annulée',
|
|
163
|
+
name: 'Nom', email: 'E-mail', phone: 'Téléphone', answers: 'Réponses', now: 'Maintenant',
|
|
164
|
+
confirmed: '{seats} · Confirmée', pending: '{seats} · En attente de votre validation', seatFreed: '{seats} libérée', seatsFreed: '{seats} libérées',
|
|
165
|
+
seatTaken: '{count} place prise', seatsTaken: '{count} places prises', seatsTakenOf: '{count} sur {capacity} places prises',
|
|
166
|
+
via: { web: 'Envoyée depuis le site', app: 'Envoyée depuis l’app', staff: 'Ajoutée par l’équipe' }, openMine: 'Ouvrir dans Mes événements',
|
|
167
|
+
hosts: 'Vous recevez ces e-mails parce que vous organisez cet événement.', noHosts: 'Vous recevez ces e-mails parce que cet événement n’a pas d’organisateur.',
|
|
168
|
+
},
|
|
129
169
|
page: {
|
|
130
170
|
untitled: 'Réservation', booked: 'Votre réservation est confirmée', yours: 'Votre réservation', moved: 'Réservation déplacée', notAvailable: 'Indisponible', cancelledTitle: 'Réservation annulée',
|
|
131
171
|
declined: 'Désolés, ce créneau n’a pas pu être confirmé. N’hésitez pas à en choisir un autre.', cancelledText: 'Cette réservation est annulée.',
|
|
@@ -159,6 +199,14 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
159
199
|
what: 'Was', when: 'Wann', where: 'Wo', seats: 'Plätze', reference: 'Referenz {ref}', icsManage: 'Verwalten: {url}',
|
|
160
200
|
lateDecline: 'Wir konnten diese Buchung nicht rechtzeitig bestätigen. Bitte wählen Sie einen anderen Termin.',
|
|
161
201
|
staff: { pending: 'Neue Anfrage zur Freigabe', booked: 'Neue Buchung', review: 'In der Verwaltung prüfen', open: 'In der Verwaltung öffnen', pendingSubject: 'Freigeben: {title} — {when}', bookedSubject: 'Neue Buchung: {title} — {when}' },
|
|
202
|
+
rsvpStaff: {
|
|
203
|
+
subject: 'Neue Anmeldung: {title}, {date}', cancelledSubject: 'Anmeldung storniert: {title}, {date}', heading: 'Neue Anmeldung für {title}', cancelledHeading: 'Anmeldung für {title} storniert',
|
|
204
|
+
name: 'Name', email: 'E-Mail', phone: 'Telefon', answers: 'Antworten', now: 'Jetzt',
|
|
205
|
+
confirmed: '{seats} · Bestätigt', pending: '{seats} · Wartet auf Ihre Freigabe', seatFreed: '{seats} frei geworden', seatsFreed: '{seats} frei geworden',
|
|
206
|
+
seatTaken: '{count} Platz belegt', seatsTaken: '{count} Plätze belegt', seatsTakenOf: '{count} von {capacity} Plätzen belegt',
|
|
207
|
+
via: { web: 'Über die Website gesendet', app: 'Über die App gesendet', staff: 'Vom Team eingetragen' }, openMine: 'In Meine Veranstaltungen öffnen',
|
|
208
|
+
hosts: 'Sie erhalten diese E-Mails, weil Sie diese Veranstaltung betreuen.', noHosts: 'Sie erhalten diese E-Mails, weil niemand diese Veranstaltung betreut.',
|
|
209
|
+
},
|
|
162
210
|
page: {
|
|
163
211
|
untitled: 'Buchung', booked: 'Ihre Buchung ist bestätigt', yours: 'Ihre Buchung', moved: 'Buchung verschoben', notAvailable: 'Nicht verfügbar', cancelledTitle: 'Buchung storniert',
|
|
164
212
|
declined: 'Leider konnte dieser Termin nicht bestätigt werden. Wählen Sie gern einen anderen.', cancelledText: 'Diese Buchung ist storniert.',
|
|
@@ -192,6 +240,14 @@ const STRINGS: Record<string, BookingStrings> = {
|
|
|
192
240
|
what: 'O quê', when: 'Quando', where: 'Onde', seats: 'Vagas', reference: 'Referência {ref}', icsManage: 'Gerenciar: {url}',
|
|
193
241
|
lateDecline: 'Não conseguimos confirmar esta reserva a tempo. Por favor, escolha outro horário.',
|
|
194
242
|
staff: { pending: 'Novo pedido para aprovar', booked: 'Nova reserva', review: 'Revisar no painel', open: 'Abrir no painel', pendingSubject: 'Aprovar: {title} — {when}', bookedSubject: 'Nova reserva: {title} — {when}' },
|
|
243
|
+
rsvpStaff: {
|
|
244
|
+
subject: 'Nova inscrição: {title}, {date}', cancelledSubject: 'Inscrição cancelada: {title}, {date}', heading: 'Nova inscrição em {title}', cancelledHeading: 'Inscrição cancelada em {title}',
|
|
245
|
+
name: 'Nome', email: 'E-mail', phone: 'Telefone', answers: 'Respostas', now: 'Agora',
|
|
246
|
+
confirmed: '{seats} · Confirmada', pending: '{seats} · Aguardando sua aprovação', seatFreed: '{seats} liberada', seatsFreed: '{seats} liberadas',
|
|
247
|
+
seatTaken: '{count} vaga ocupada', seatsTaken: '{count} vagas ocupadas', seatsTakenOf: '{count} de {capacity} vagas ocupadas',
|
|
248
|
+
via: { web: 'Enviada pelo site', app: 'Enviada pelo app', staff: 'Adicionada pela equipe' }, openMine: 'Abrir em Meus eventos',
|
|
249
|
+
hosts: 'Você recebe estes e-mails porque organiza este evento.', noHosts: 'Você recebe estes e-mails porque este evento não tem organizadores.',
|
|
250
|
+
},
|
|
195
251
|
page: {
|
|
196
252
|
untitled: 'Reserva', booked: 'Sua reserva está confirmada', yours: 'Sua reserva', moved: 'Reserva remarcada', notAvailable: 'Indisponível', cancelledTitle: 'Reserva cancelada',
|
|
197
253
|
declined: 'Desculpe, não foi possível confirmar este horário. Fique à vontade para escolher outro.', cancelledText: 'Esta reserva foi cancelada.',
|
package/src/lib/email/service.ts
CHANGED
|
@@ -22,7 +22,7 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
|
|
|
22
22
|
const b = await repo.getBooking(payload.bookingId);
|
|
23
23
|
if (!b || (b.notificationVersion ?? 0) !== payload.version) return null;
|
|
24
24
|
if (payload.kind === 'reminder' && (b.status !== 'confirmed' || b.start <= Date.now())) return null;
|
|
25
|
-
const mail = await bookingMail(repo, b, payload.kind, origin, from, payload);
|
|
25
|
+
const mail = await bookingMail(repo, b, payload.kind, origin, from, payload, job.recipient);
|
|
26
26
|
return { ...mail, to: job.recipient };
|
|
27
27
|
}
|
|
28
28
|
if (job.kind === 'auth') {
|
package/src/lib/email/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SqlDriver, SqlStatement } from '../repo/sql';
|
|
2
|
-
import type { ConfigRecord, EmailConfig, EmailJob, Invitation, NewEmailJob, SendResult } from './types';
|
|
2
|
+
import type { ConfigRecord, EmailConfig, EmailJob, Invitation, NewEmailJob, RsvpStaffNotice, SendResult } from './types';
|
|
3
3
|
const record = (r: Record<string, unknown>): ConfigRecord => ({ version: Number(r.version), config: JSON.parse(String(r.config)), encrypted: String(r.encrypted), createdAt: Number(r.created_at) });
|
|
4
4
|
function job(r: Record<string, unknown>): EmailJob {
|
|
5
5
|
return { id: String(r.id), dedupe: String(r.dedupe), kind: String(r.kind), recipient: String(r.recipient), payload: String(r.payload), configVersion: Number(r.config_version), state: r.state as EmailJob['state'], attempts: Number(r.attempts), nextAt: Number(r.next_at), expiresAt: r.expires_at == null ? null : Number(r.expires_at), createdAt: Number(r.created_at), lease: String(r.lease), leaseUntil: r.lease_until == null ? null : Number(r.lease_until), provider: String(r.provider), providerId: String(r.provider_id), error: String(r.error), acceptedAt: r.accepted_at == null ? null : Number(r.accepted_at), updatedAt: Number(r.updated_at), fingerprint: String(r.fingerprint), rendered: String(r.rendered || '') };
|
|
@@ -25,6 +25,22 @@ export function bookingJobs(id: string, kind: string, version?: number, staff =
|
|
|
25
25
|
});
|
|
26
26
|
return [select('b.email', 'bookings b', kind), ...(staff ? [select('j.value', 'bookings b JOIN resources r ON r.id = b.resource_id, json_each(r.notify_emails) j', 'staff-new')] : [])];
|
|
27
27
|
}
|
|
28
|
+
// Staff hear about an RSVP (new, or cancelled by the person) at the addresses
|
|
29
|
+
// the caller picked, in the same batch and only if the change landed.
|
|
30
|
+
// `taken` is the date's seat count right after it.
|
|
31
|
+
export function rsvpStaffJobs(id: string, kind: 'staff-rsvp' | 'staff-rsvp-cancelled', notice: RsvpStaffNotice | undefined, version: number, mutation = ''): SqlStatement[] {
|
|
32
|
+
if (!notice?.to.length) return [];
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
return [{
|
|
35
|
+
sql: `INSERT OR IGNORE INTO email_jobs (id,dedupe,kind,recipient,payload,config_version,next_at,expires_at,created_at,updated_at)
|
|
36
|
+
SELECT lower(hex(randomblob(16))), b.id || ':' || b.notification_version || ':' || ? || ':' || lower(j.value), 'booking', lower(j.value),
|
|
37
|
+
json_object('bookingId',b.id,'version',b.notification_version,'kind',?,'why',?,'via',?,
|
|
38
|
+
'taken',(SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=b.event_id AND start_at=b.start_at AND status IN ('pending','confirmed'))),
|
|
39
|
+
COALESCE((SELECT MAX(version) FROM email_configs),0), ?, NULL, ?, ?
|
|
40
|
+
FROM bookings b, json_each(?) j WHERE b.id = ? AND b.kind = 'rsvp' AND j.value != '' AND b.notification_version = ? ${mutation ? 'AND b.email_mutation = ?' : ''}`,
|
|
41
|
+
args: [kind, kind, notice.why, notice.via, now, now, now, JSON.stringify(notice.to), id, version, ...(mutation ? [mutation] : [])],
|
|
42
|
+
}];
|
|
43
|
+
}
|
|
28
44
|
export class EmailStore {
|
|
29
45
|
constructor(private getDriver: () => Promise<SqlDriver>) {}
|
|
30
46
|
async config(version?: number): Promise<ConfigRecord | null> {
|
package/src/lib/email/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Role } from '../schema';
|
|
1
|
+
import type { Booking, Role } from '../schema';
|
|
2
2
|
export type Provider = 'resend' | 'mailgun' | 'ses' | 'cloudflare';
|
|
3
3
|
export type ProviderChoice = Provider | 'auto' | 'none';
|
|
4
4
|
export interface Attachment { filename: string; type: string; content: string }
|
|
@@ -19,6 +19,8 @@ export interface EmailJob {
|
|
|
19
19
|
acceptedAt: number | null; updatedAt: number; fingerprint: string;
|
|
20
20
|
rendered: string;
|
|
21
21
|
}
|
|
22
|
+
/** staff to tell about an RSVP (booking/service.ts rsvpStaff): to its hosts, else the contact address; via: where the person did it */
|
|
23
|
+
export interface RsvpStaffNotice { to: string[]; why: 'hosts' | 'contact'; via: Booking['source'] }
|
|
22
24
|
export interface NewEmailJob { id?: string; dedupe: string; kind: string; recipient: string; payload: string; configVersion: number; nextAt?: number; expiresAt?: number }
|
|
23
25
|
export type SendResult = { state: 'accepted'; providerId: string } | { state: 'retrying' | 'failed' | 'unknown'; error: string; retryAfter?: number };
|
|
24
26
|
export interface Invitation { id: string; email: string; name: string; role: Role; state: string; expiresAt: number; createdAt: number; invitedBy: string }
|
package/src/lib/legal/text-en.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
|
42
42
|
] },
|
|
43
43
|
];
|
|
44
44
|
if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Bookings and RSVPs' : 'Event RSVPs', text: [
|
|
45
|
-
`When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}.`,
|
|
45
|
+
`When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}. When you RSVP or cancel an RSVP, the event’s hosts (or our staff) get an email with your RSVP details.`,
|
|
46
46
|
'Each booking has a private link to see, change or cancel it. Keep it private: anyone with the link can use it.',
|
|
47
47
|
] });
|
|
48
48
|
s.push({ id: 'forms', title: 'Forms', text: [
|
package/src/lib/legal/text-es.ts
CHANGED
|
@@ -43,7 +43,7 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
|
43
43
|
] },
|
|
44
44
|
];
|
|
45
45
|
if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Reservaciones y registros' : 'Registros a eventos', text: [
|
|
46
|
-
`Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}.`,
|
|
46
|
+
`Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}. Cuando se registra a un evento o cancela su registro, los organizadores del evento (o nuestro personal) reciben un correo con los datos de su registro.`,
|
|
47
47
|
'Cada reservación tiene un enlace privado para verla, cambiarla o cancelarla. Manténgalo en privado: cualquiera que lo tenga puede usarlo.',
|
|
48
48
|
] });
|
|
49
49
|
s.push({ id: 'forms', title: 'Formularios', text: [
|
|
@@ -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
|
@@ -75,7 +75,7 @@ export const getManaged = async (c: PublicContext) => view(await managed(c));
|
|
|
75
75
|
|
|
76
76
|
export async function cancelManaged(c: PublicContext) {
|
|
77
77
|
const m = await managed(c);
|
|
78
|
-
return view(m, await guestCancel(c.repo, m.booking, flow(m, c)));
|
|
78
|
+
return view(m, await guestCancel(c.repo, m.booking, flow(m, c), 'app'));
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
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')],
|
|
@@ -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 })),
|
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';
|
|
@@ -1241,7 +1241,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1241
1241
|
SELECT ?,?,id,? FROM bookings WHERE id=?`, args: [keyHash, requestHash, now, b.id] });
|
|
1242
1242
|
}
|
|
1243
1243
|
// 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)]);
|
|
1244
|
+
await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : 'received', 0, true), ...rsvpStaffJobs(b.id, 'staff-rsvp', guard.staff, 0)]);
|
|
1245
1245
|
return this.getBooking(b.id);
|
|
1246
1246
|
}
|
|
1247
1247
|
async moveBooking(id: string, t: { start: number; end: number; holdStart: number; holdEnd: number }) {
|
|
@@ -1278,7 +1278,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
1278
1278
|
args.push(id);
|
|
1279
1279
|
const kind = data.status === 'confirmed' ? 'confirmed' : data.status === 'declined' ? 'declined' : data.status === 'pending' ? 'received' : 'cancelled';
|
|
1280
1280
|
await this.driver.batch([{sql:`UPDATE bookings SET ${sets.join(', ')} WHERE id = ? AND notification_version = ?`,args:[...args,current.notificationVersion]},
|
|
1281
|
-
...(changed ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : [])
|
|
1281
|
+
...(changed ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : []),
|
|
1282
|
+
...(changed && data.status === 'cancelled' ? rsvpStaffJobs(id,'staff-rsvp-cancelled',data.staff,current.notificationVersion+1,mutation) : [])]);
|
|
1282
1283
|
}
|
|
1283
1284
|
async deleteBooking(id: string) {
|
|
1284
1285
|
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,7 +190,8 @@ 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>;
|
|
@@ -199,11 +201,12 @@ export interface ContentRepository {
|
|
|
199
201
|
// locale: the booker's language; '' (the main language) when left out
|
|
200
202
|
createBooking(
|
|
201
203
|
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale'> & { locale?: string },
|
|
202
|
-
guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string } }
|
|
204
|
+
guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string }; staff?: RsvpStaffNotice }
|
|
203
205
|
): Promise<Booking | null>;
|
|
204
206
|
// moves a booking only if the new held range is free (reschedule)
|
|
205
207
|
moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
|
|
206
|
-
|
|
208
|
+
// staff: who hears that the person cancelled their RSVP (status → cancelled only)
|
|
209
|
+
updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { notificationMessage?: string; staff?: RsvpStaffNotice }): Promise<void>;
|
|
207
210
|
deleteBooking(id: string): Promise<void>;
|
|
208
211
|
// confirmed bookings starting in [from, to) that haven't had a reminder
|
|
209
212
|
listDueReminders(from: number, to: number): Promise<Booking[]>;
|
package/src/lib/schema.ts
CHANGED
|
@@ -417,7 +417,9 @@ 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).
|
|
421
423
|
rsvp: z.object({
|
|
422
424
|
enabled: z.boolean().default(false),
|
|
423
425
|
capacity: z.number().int().default(0),
|
|
@@ -425,6 +427,7 @@ export const CalEvent = z.object({
|
|
|
425
427
|
maxSeats: z.number().int().default(4),
|
|
426
428
|
closesAt: z.string().nullable().default(null),
|
|
427
429
|
closeBefore: z.number().int().default(0),
|
|
430
|
+
notifyStaff: z.boolean().default(true),
|
|
428
431
|
form: FormDefinition.optional(),
|
|
429
432
|
}).default({}),
|
|
430
433
|
// rooms/people this event uses: they can't be booked while it happens (or
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getEventDetail } from '../../../../../lib/mobile/calendar';
|
|
2
|
+
import { readRoute } from '../../../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
|
|
5
|
+
// the next occurrence moves with the clock: not timeless
|
|
6
|
+
export const GET = readRoute(getEventDetail, true);
|
|
7
|
+
export const HEAD = GET;
|
|
8
|
+
export const ALL = () => methodNotAllowed();
|