@acms-subir/core 0.3.34 → 0.3.36
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 +29 -0
- package/cli/migrations.generated.mjs +7 -1
- package/docs/mobile.md +9 -6
- package/docs/theme-authoring.md +5 -4
- package/integration.mjs +3 -1
- package/mobile-template/client/AGENTS.md +2 -1
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
- package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +270 -0
- package/mobile-template/recipes/events/recipe.json +6 -3
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +270 -0
- package/mobile-template/recipes/rsvp/recipe.json +15 -0
- package/package.json +1 -1
- package/src/components/Announcements.tsx +14 -1
- package/src/components/LiveCard.astro +23 -10
- package/src/components/MobileApp.tsx +54 -4
- package/src/layouts/Site.astro +45 -38
- package/src/lib/announcements/api.ts +8 -4
- package/src/lib/announcements/store.ts +4 -0
- package/src/lib/cache/policy.ts +2 -2
- package/src/lib/calendar/hooks.ts +5 -1
- package/src/lib/legal/app-privacy.ts +5 -3
- package/src/lib/legal/text-en.ts +1 -0
- package/src/lib/legal/text-es.ts +1 -0
- package/src/lib/live/view.ts +28 -24
- package/src/lib/live/words.ts +94 -0
- package/src/lib/mobile/bookings.ts +17 -11
- package/src/lib/mobile/forms.ts +21 -11
- package/src/lib/mobile/manage.ts +2 -0
- package/src/lib/mobile/openapi.ts +8 -3
- package/src/lib/mobile/rsvp.ts +65 -0
- package/src/lib/mobile/schemas.ts +9 -1
- package/src/lib/mobile/site.ts +3 -1
- package/src/lib/push/api.ts +13 -1
- package/src/lib/push/service.ts +211 -24
- package/src/lib/push/store.ts +35 -8
- package/src/lib/push/types.ts +23 -2
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/schema.ts +2 -1
- package/src/lib/search-i18n.ts +41 -4
- package/src/lib/site-chrome.ts +8 -0
- package/src/lib/site-words.ts +62 -0
- package/src/pages/admin/calendar/bookings.astro +3 -1
- package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
- package/src/styles/mobile-app.css +7 -0
- package/src/theme-sdk/index.ts +2 -0
|
@@ -4,33 +4,38 @@ import type { Booking } from '../schema';
|
|
|
4
4
|
import { ApiError, readJson } from '../api';
|
|
5
5
|
import { sha256Hex } from '../api-keys';
|
|
6
6
|
import { siteOrigin } from '../seo';
|
|
7
|
-
import { eventIsPublic, expandEvents } from '../calendar/events';
|
|
8
7
|
import { createResourceBooking, createRsvp, bookingJson } from '../booking/service';
|
|
9
8
|
import { waitUntilFrom } from '../webhooks';
|
|
10
9
|
import { requireCalendar } from './calendar';
|
|
10
|
+
import { appAnswers, joinAnswers } from './forms';
|
|
11
|
+
import { rsvpClosed, rsvpOccurrence, rsvpQuestions } from './rsvp';
|
|
11
12
|
|
|
12
13
|
const common = {
|
|
13
14
|
start: z.string().datetime({ offset: true }),
|
|
14
15
|
name: z.string().trim().min(1).max(120), email: z.string().trim().email().max(200).transform(v => v.toLowerCase()),
|
|
15
16
|
phone: z.string().trim().max(40).default(''),
|
|
17
|
+
answers: appAnswers.default({}),
|
|
16
18
|
};
|
|
17
19
|
export const bookingInput = z.union([
|
|
18
20
|
z.object({ ...common, resource: z.string().min(1).max(200), link: z.string().min(1).max(200),
|
|
19
|
-
minutes: z.number().int().min(1).max(44640).optional(),
|
|
21
|
+
minutes: z.number().int().min(1).max(44640).optional(),
|
|
20
22
|
}).strict(),
|
|
21
|
-
|
|
23
|
+
// formVersion: from GET /events/{slug}/rsvp; required when the event asks questions
|
|
24
|
+
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() }).strict(),
|
|
22
25
|
]);
|
|
23
26
|
function canonical(value: unknown): string {
|
|
24
27
|
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(',')}}`;
|
|
25
28
|
return JSON.stringify(value);
|
|
26
29
|
}
|
|
27
30
|
const bookingData = (booking: Booking) => ({ booking: bookingJson(booking), manageToken: booking.manageToken });
|
|
31
|
+
// RSVPs need only the calendar (like the event's web page); room bookings need bookings too
|
|
28
32
|
export async function submitBooking(repo: ContentRepository, request: Request, locals?: unknown) {
|
|
29
|
-
requireCalendar(
|
|
33
|
+
requireCalendar();
|
|
30
34
|
const key = request.headers.get('idempotency-key') ?? '';
|
|
31
35
|
// Treat the key as a secret receipt. Require a fresh UUID per user intent.
|
|
32
36
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) throw new ApiError(400, 'Send a random UUID v4 in Idempotency-Key and reuse it only for retries.');
|
|
33
37
|
const body = bookingInput.parse(await readJson(request, 50000));
|
|
38
|
+
if (!('event' in body)) requireCalendar(true);
|
|
34
39
|
const idempotency = { keyHash: await sha256Hex(key.toLowerCase()), requestHash: await sha256Hex(canonical(body)) };
|
|
35
40
|
const replay = async () => {
|
|
36
41
|
const record = await repo.getBookingRequest(idempotency.keyHash);
|
|
@@ -46,21 +51,22 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
46
51
|
const settings = await repo.getSettings();
|
|
47
52
|
const flow = { origin: siteOrigin(settings, new URL(request.url)), locals, waitUntil: waitUntilFrom(locals), idempotency };
|
|
48
53
|
const start = Date.parse(body.start);
|
|
54
|
+
const booker = { ...body, answers: joinAnswers(body.answers) };
|
|
49
55
|
let booking: Booking;
|
|
50
56
|
if ('event' in body) {
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
if (!event || !eventIsPublic(event, calendars)) throw new ApiError(404, 'Event not found.');
|
|
57
|
+
const occurrence = await rsvpOccurrence(repo, body.event, body.start);
|
|
58
|
+
const { event } = occurrence;
|
|
54
59
|
if (body.seats > (event.rsvp.maxSeats || 1)) throw new ApiError(400, 'Too many seats for one RSVP.');
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
// the app must have seen the current questions: a changed form then answers 409, as on the website
|
|
61
|
+
// (closed dates fall through to createRsvp's own answer)
|
|
62
|
+
if (!body.formVersion && rsvpQuestions(event) && !rsvpClosed(occurrence)) throw new ApiError(400, 'This event asks questions. Send formVersion from GET /api/v1/events/{slug}/rsvp with the answers.');
|
|
63
|
+
booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker, source: 'app' }, flow);
|
|
58
64
|
} else {
|
|
59
65
|
const resource = await repo.getResourceBySlug(body.resource);
|
|
60
66
|
const link = resource ? await repo.getBookingLinkBySlug(resource.id, body.link) : null;
|
|
61
67
|
if (!resource || !link) throw new ApiError(404, 'Resource or booking link not found.');
|
|
62
68
|
const minutes = body.minutes ?? (link.mode === 'day' ? 1440 : link.mode === 'range' ? link.lengths[0] ?? link.duration : link.duration);
|
|
63
|
-
booking = await createResourceBooking(repo, { resource, link, start, end: start + minutes * 60000, booker
|
|
69
|
+
booking = await createResourceBooking(repo, { resource, link, start, end: start + minutes * 60000, booker, source: 'app' }, flow);
|
|
64
70
|
}
|
|
65
71
|
return { data: bookingData(booking), replayed: false };
|
|
66
72
|
} catch (error) {
|
package/src/lib/mobile/forms.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import type { SavedForm } from '../forms/schema';
|
|
3
|
+
import type { FormDefinition, SavedForm } from '../forms/schema';
|
|
4
4
|
import { collectResponse, formVersion } from '../forms/service';
|
|
5
5
|
import { ApiError, readJson } from '../api';
|
|
6
6
|
import { sha256Hex } from '../api-keys';
|
|
@@ -24,14 +24,11 @@ async function publishedForm(repo: ContentRepository, id: string): Promise<Saved
|
|
|
24
24
|
return form;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
await requireAccess(repo, 'forms');
|
|
30
|
-
const form = await publishedForm(repo, id);
|
|
31
|
-
const d = form.definition;
|
|
27
|
+
/** A form's questions for apps; `version` is the one collectResponse accepts (also for event RSVPs). */
|
|
28
|
+
export async function formJson(d: FormDefinition) {
|
|
32
29
|
return {
|
|
33
|
-
|
|
34
|
-
title: d.title
|
|
30
|
+
version: await formVersion(d),
|
|
31
|
+
title: d.title, intro: d.intro, submitLabel: d.submitLabel, confirmation: d.confirmation,
|
|
35
32
|
fields: d.fields.map((f) => ({
|
|
36
33
|
id: f.id, type: f.type, label: f.label, required: f.required, help: f.help, options: f.options,
|
|
37
34
|
placeholder: f.placeholder ?? '', width: f.width ?? 'full', allowOther: f.allowOther ?? false,
|
|
@@ -39,9 +36,22 @@ export async function getForm(repo: ContentRepository, url: URL, id: string) {
|
|
|
39
36
|
};
|
|
40
37
|
}
|
|
41
38
|
|
|
39
|
+
export async function getForm(repo: ContentRepository, url: URL, id: string) {
|
|
40
|
+
z.object({}).strict().parse(queryParams(url));
|
|
41
|
+
await requireAccess(repo, 'forms');
|
|
42
|
+
const form = await publishedForm(repo, id);
|
|
43
|
+
const d = form.definition;
|
|
44
|
+
return { id: form.id, ...await formJson(d), title: d.title || form.name, submitLabel: d.submitLabel || 'Submit' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// field id → answer; checkbox groups send a list. "Other" answers send the typed text.
|
|
48
|
+
export const appAnswers = z.record(z.union([z.string().max(2000), z.array(z.string().max(2000)).max(51)]));
|
|
49
|
+
/** lists become the stored newline-joined form */
|
|
50
|
+
export const joinAnswers = (answers: z.infer<typeof appAnswers>): Record<string, string> =>
|
|
51
|
+
Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, Array.isArray(v) ? v.map((x) => x.trim()).filter(Boolean).join('\n') : v]));
|
|
52
|
+
|
|
42
53
|
export const formResponseInput = z.object({
|
|
43
|
-
|
|
44
|
-
answers: z.record(z.union([z.string().max(2000), z.array(z.string().max(2000)).max(51)])).refine((a) => Object.keys(a).length <= 30, 'Too many answers.'),
|
|
54
|
+
answers: appAnswers.refine((a) => Object.keys(a).length <= 30, 'Too many answers.'),
|
|
45
55
|
// the version from GET /forms/{id}; a changed form answers 409 instead of saving
|
|
46
56
|
version: z.string().max(100).optional(),
|
|
47
57
|
// spam trap: apps leave it out or empty
|
|
@@ -57,7 +67,7 @@ export async function submitFormResponse(c: PublicContext) {
|
|
|
57
67
|
if (body.website) throw new ApiError(400, 'Could not submit this form.');
|
|
58
68
|
const form = await publishedForm(c.repo, id);
|
|
59
69
|
if (!await c.repo.email.rateLimit(`forms:app:${await sha256Hex(`${form.id}:${c.address}`)}`, 10, 60_000)) throw new ApiError(429, 'Too many responses. Please try again in a minute.');
|
|
60
|
-
const answers =
|
|
70
|
+
const answers = joinAnswers(body.answers);
|
|
61
71
|
// the key is the stored response id: a retry of a saved response answers
|
|
62
72
|
// as before and costs nothing (also after the form changed); the same key
|
|
63
73
|
// with different answers is a 409
|
package/src/lib/mobile/manage.ts
CHANGED
|
@@ -63,6 +63,8 @@ function view(m: Managed, booking = m.booking) {
|
|
|
63
63
|
// the web page for this booking (the same private link the emails carry)
|
|
64
64
|
manageUrl: origin + manageUrl(booking.manageToken),
|
|
65
65
|
can,
|
|
66
|
+
// the RSVP form as it was when they sent it (like the web page), else the booking type's message
|
|
67
|
+
confirmation: booking.formResponse?.definition.confirmation || link?.confirmMessage || null,
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
const flow = (m: Managed, c: PublicContext) => ({ origin: m.origin, locals: c.locals, waitUntil: waitUntilFrom(c.locals) });
|
|
@@ -4,6 +4,7 @@ import type { ContentRepository } from '../repo/types';
|
|
|
4
4
|
import { contentQuery, contentDetailQuery } from './content';
|
|
5
5
|
import { eventsQuery, availabilityQuery } from './calendar';
|
|
6
6
|
import { bookingInput } from './bookings';
|
|
7
|
+
import { rsvpQuery } from './rsvp';
|
|
7
8
|
import { pagesQuery } from './pages';
|
|
8
9
|
import { searchQuery } from './search';
|
|
9
10
|
import { manageAvailabilityQuery, rescheduleInput } from './manage';
|
|
@@ -33,7 +34,10 @@ const errors = Object.fromEntries([400, 404, 405, 429, 500].map(status => [statu
|
|
|
33
34
|
...(status === 429 ? { headers: { 'Retry-After': { description: 'Seconds to wait: 60 for per-minute limits, longer for hourly, daily and lockout limits.', schema: { type: 'string' } } } } : {}),
|
|
34
35
|
}]));
|
|
35
36
|
function parameters(query: z.AnyZodObject) {
|
|
36
|
-
return Object.entries(query.shape).map(([name, schema]) =>
|
|
37
|
+
return Object.entries(query.shape).map(([name, schema]) => {
|
|
38
|
+
const s = schema as z.ZodTypeAny;
|
|
39
|
+
return { name, in: 'query', required: !s.isOptional(), ...(s.description ? { description: s.description } : {}), schema: jsonSchema(s) };
|
|
40
|
+
});
|
|
37
41
|
}
|
|
38
42
|
export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
39
43
|
empty.parse(queryParams(url));
|
|
@@ -48,6 +52,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
48
52
|
['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
|
|
49
53
|
['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
|
|
50
54
|
['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
|
|
55
|
+
['/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],
|
|
51
56
|
['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
|
|
52
57
|
['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
|
|
53
58
|
['/push/topics', 'Topics an app can offer as notification toggles, named in the requested language; requires push', localeOnlyQuery, ref('PushTopics')],
|
|
@@ -73,8 +78,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
73
78
|
}
|
|
74
79
|
paths['/api/v1/bookings'] = {
|
|
75
80
|
post: {
|
|
76
|
-
summary: 'Create a guest booking or replay an existing request;
|
|
77
|
-
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.',
|
|
81
|
+
summary: 'Create a guest booking or an RSVP, or replay an existing request; RSVPs require calendar, room bookings also bookings',
|
|
82
|
+
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409.',
|
|
78
83
|
parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
|
|
79
84
|
requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
|
|
80
85
|
responses: { ...errors,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import type { CalEvent } from '../schema';
|
|
4
|
+
import { ApiError } from '../api';
|
|
5
|
+
import { answerFields } from '../forms/schema';
|
|
6
|
+
import { eventIsPublic, expandEvents, type Occurrence } from '../calendar/events';
|
|
7
|
+
import { seatsTaken } from '../booking/service';
|
|
8
|
+
import { localizeDoc } from '../translations';
|
|
9
|
+
import { queryParams } from './http';
|
|
10
|
+
import { requireCalendar } from './calendar';
|
|
11
|
+
import { formJson } from './forms';
|
|
12
|
+
import { localeQuery, mobileLocale } from './locale';
|
|
13
|
+
|
|
14
|
+
// RSVP status of one date of an event, for apps: whether it takes RSVPs now,
|
|
15
|
+
// live seats and the questions with their version. The same rules as the
|
|
16
|
+
// event's web page: open until the date ends, no deadline or waitlist.
|
|
17
|
+
// Never cached; seats change with every RSVP. POST /bookings saves one.
|
|
18
|
+
|
|
19
|
+
/** a public event (slug or id) and its occurrence starting exactly at `start` */
|
|
20
|
+
export async function rsvpOccurrence(repo: ContentRepository, ref: string, start: string): Promise<Occurrence> {
|
|
21
|
+
const event = await repo.getEventBySlug(ref) ?? await repo.getEvent(ref);
|
|
22
|
+
const calendars = new Map((await repo.listCalendars()).map(c => [c.id, c]));
|
|
23
|
+
if (!event || !eventIsPublic(event, calendars)) throw new ApiError(404, 'Event not found.');
|
|
24
|
+
const settings = await repo.getSettings();
|
|
25
|
+
const at = Date.parse(start);
|
|
26
|
+
const occurrence = expandEvents([event], settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: at, windowEndMs: at + 1 }).find(o => o.start === at);
|
|
27
|
+
if (!occurrence) throw new ApiError(400, 'Start must match an event occurrence exactly.');
|
|
28
|
+
return occurrence;
|
|
29
|
+
}
|
|
30
|
+
/** the RSVP form when it asks something; apps must then send its version */
|
|
31
|
+
export const rsvpQuestions = (e: CalEvent) => e.rsvp.form && answerFields(e.rsvp.form).length ? e.rsvp.form : null;
|
|
32
|
+
/** why this date takes no RSVPs (seats aside), or null */
|
|
33
|
+
export function rsvpClosed(o: Occurrence, now = Date.now()) {
|
|
34
|
+
if (!o.event.rsvp.enabled) return 'disabled' as const;
|
|
35
|
+
if (o.cancelled) return 'cancelled' as const;
|
|
36
|
+
if (o.end <= now) return 'ended' as const;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const rsvpQuery = z.object({
|
|
41
|
+
start: z.string().datetime({ offset: true }).describe('The occurrence’s start exactly as GET /events returns it, a UTC ISO date-time such as 2026-10-05T14:00:00.000Z. A start that isn’t one of the event’s dates answers 400.'),
|
|
42
|
+
locale: localeQuery,
|
|
43
|
+
}).strict();
|
|
44
|
+
export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: string) {
|
|
45
|
+
requireCalendar();
|
|
46
|
+
const q = rsvpQuery.parse(queryParams(url));
|
|
47
|
+
const locale = mobileLocale(q.locale);
|
|
48
|
+
const o = await rsvpOccurrence(repo, slug, q.start);
|
|
49
|
+
const { rsvp } = o.event;
|
|
50
|
+
// pending and confirmed RSVPs hold seats, as in the capacity check
|
|
51
|
+
const going = rsvp.enabled ? await seatsTaken(repo, o.event.id, o.start) : 0;
|
|
52
|
+
const left = rsvp.capacity ? Math.max(0, rsvp.capacity - going) : null;
|
|
53
|
+
const closed = rsvpClosed(o);
|
|
54
|
+
const reason = closed ?? (left === 0 ? 'full' as const : null);
|
|
55
|
+
const form = rsvpQuestions(o.event);
|
|
56
|
+
const title = localizeDoc(o.event, locale ?? undefined).title;
|
|
57
|
+
return {
|
|
58
|
+
event: { id: o.event.id, slug: o.event.slug, title },
|
|
59
|
+
start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
60
|
+
open: !reason, reason, going, left, capacity: rsvp.capacity,
|
|
61
|
+
// seats one RSVP may take right now
|
|
62
|
+
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
63
|
+
form: form ? { ...await formJson(form), submitLabel: form.submitLabel || (rsvp.autoConfirm ? 'Confirm my seat' : 'Request a seat') } : null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -8,7 +8,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
|
|
|
8
8
|
export const Site = z.object({
|
|
9
9
|
name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
|
|
10
10
|
languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
|
|
11
|
-
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool }),
|
|
11
|
+
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, rsvp: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool }),
|
|
12
12
|
legal: z.object({ privacy: str.nullable(), terms: str.nullable() }), apiVersion: z.literal(1),
|
|
13
13
|
});
|
|
14
14
|
export const Theme = z.object({
|
|
@@ -68,6 +68,12 @@ export const Form = z.object({
|
|
|
68
68
|
})),
|
|
69
69
|
});
|
|
70
70
|
export const FormReceipt = z.object({ received: z.literal(true), confirmation: str });
|
|
71
|
+
export const RsvpStatus = z.object({
|
|
72
|
+
event: z.object({ id: str, slug: str, title: str }), start: date, end: date,
|
|
73
|
+
open: bool, reason: z.enum(['disabled', 'ended', 'cancelled', 'full']).nullable(),
|
|
74
|
+
going: z.number().int(), left: z.number().int().nullable(), capacity: z.number().int(), maxSeats: z.number().int(), autoConfirm: bool,
|
|
75
|
+
form: Form.omit({ id: true }).nullable(),
|
|
76
|
+
});
|
|
71
77
|
const GuestChanges = z.object({ cancel: bool, reschedule: bool, cutoffHours: z.number().int(), closed: bool });
|
|
72
78
|
export const ManagedBooking = z.object({
|
|
73
79
|
booking: z.object({
|
|
@@ -79,6 +85,8 @@ export const ManagedBooking = z.object({
|
|
|
79
85
|
link: z.object({ slug: str, name: str, mode: z.enum(['slots', 'range', 'day']), lengths: z.array(z.number()) }).nullable(),
|
|
80
86
|
event: z.object({ slug: str, title: str, url: str }).nullable(),
|
|
81
87
|
manageUrl: str, can: GuestChanges,
|
|
88
|
+
// what the web page says once it's confirmed: the RSVP form's or booking type's message
|
|
89
|
+
confirmation: str.nullable(),
|
|
82
90
|
});
|
|
83
91
|
export const GuestCodeSent = z.object({ sent: z.literal(true), expiresInMinutes: z.number().int() });
|
|
84
92
|
export const GuestToken = z.object({ accessToken: str, tokenType: z.literal('Bearer'), email: str, idleDays: z.number().int() });
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -37,7 +37,9 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
37
37
|
features: {
|
|
38
38
|
// redirectFrom: old addresses the website 301s to url, so apps can follow them too,
|
|
39
39
|
// minus any another collection now lives at (the website serves that one first)
|
|
40
|
-
blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin), redirectFrom: legacyBases(key, c).filter(from => !bases.has(from)).map(from => safeUrl(from, origin)) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(),
|
|
40
|
+
blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin), redirectFrom: legacyBases(key, c).filter(from => !bases.has(from)).map(from => safeUrl(from, origin)) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(),
|
|
41
|
+
// RSVPs from apps (GET /events/{slug}/rsvp, POST /bookings) come with the calendar
|
|
42
|
+
rsvp: CALENDAR.enabled, search: SEARCH.enabled,
|
|
41
43
|
// Admin → Mobile app → App access
|
|
42
44
|
bookingChanges: CALENDAR.enabled && settings.mobileAccess.bookingChanges, guestSignIn: await guestSignInReady(repo, locals),
|
|
43
45
|
forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
|
package/src/lib/push/api.ts
CHANGED
|
@@ -101,10 +101,11 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
|
|
|
101
101
|
const now = Date.now();
|
|
102
102
|
const settings = await repo.getSettings();
|
|
103
103
|
const expo = expoOptions(locals);
|
|
104
|
-
const [stats, topics, calendars, links, log] = await Promise.all([
|
|
104
|
+
const [stats, topics, calendars, links, log, tags] = await Promise.all([
|
|
105
105
|
repo.push.stats(now), pushTopics(repo),
|
|
106
106
|
CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
|
|
107
107
|
linkTargets(repo), repo.push.listNotifications(50),
|
|
108
|
+
CALENDAR.enabled ? repo.listTags() : Promise.resolve([]),
|
|
108
109
|
]);
|
|
109
110
|
return {
|
|
110
111
|
enabled: PUSH.enabled, retentionDays: PUSH.retentionDays,
|
|
@@ -115,6 +116,8 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
|
|
|
115
116
|
// "we're live" needs live video on (Integrations → Live video)
|
|
116
117
|
liveMode: settings.live.mode,
|
|
117
118
|
calendars: calendars.filter((c) => c.visibility === 'public').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
|
|
119
|
+
// suggestions for the event-change rule's "Only events tagged"
|
|
120
|
+
tags: tags.map((t) => t.name),
|
|
118
121
|
expo: { accessToken: Boolean(expo.accessToken), customEndpoint: expo.base !== EXPO_PUSH_BASE },
|
|
119
122
|
};
|
|
120
123
|
}
|
|
@@ -152,11 +155,20 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
|
|
|
152
155
|
const collections = new Set(Object.keys(CONTENT.collections));
|
|
153
156
|
const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
|
|
154
157
|
const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
|
|
158
|
+
const changes = input.auto.eventChanges;
|
|
159
|
+
const tags = changes?.tags ?? [];
|
|
160
|
+
const known = await knownTopics(repo);
|
|
155
161
|
const auto = {
|
|
156
162
|
newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
|
|
157
163
|
newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
|
|
158
164
|
startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
|
|
159
165
|
live: { enabled: input.auto.live?.enabled === true },
|
|
166
|
+
eventChanges: CALENDAR.enabled ? {
|
|
167
|
+
calendars: cals(changes?.calendars ?? []),
|
|
168
|
+
// one of each tag, whatever its case (matching ignores case too)
|
|
169
|
+
tags: tags.filter((t, i) => tags.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i),
|
|
170
|
+
topics: [...new Set(changes?.topics ?? [])].filter((t) => known.has(t)),
|
|
171
|
+
} : { calendars: [], tags: [], topics: [] },
|
|
160
172
|
};
|
|
161
173
|
await repo.updateSettings({ push: { auto } });
|
|
162
174
|
return { auto };
|