@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +7 -1
  3. package/docs/mobile.md +9 -6
  4. package/docs/theme-authoring.md +5 -4
  5. package/integration.mjs +3 -1
  6. package/mobile-template/client/AGENTS.md +2 -1
  7. package/mobile-template/client/README.md +1 -1
  8. package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
  9. package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
  10. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +270 -0
  11. package/mobile-template/recipes/events/recipe.json +6 -3
  12. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +270 -0
  13. package/mobile-template/recipes/rsvp/recipe.json +15 -0
  14. package/package.json +1 -1
  15. package/src/components/Announcements.tsx +14 -1
  16. package/src/components/LiveCard.astro +23 -10
  17. package/src/components/MobileApp.tsx +54 -4
  18. package/src/layouts/Site.astro +45 -38
  19. package/src/lib/announcements/api.ts +8 -4
  20. package/src/lib/announcements/store.ts +4 -0
  21. package/src/lib/cache/policy.ts +2 -2
  22. package/src/lib/calendar/hooks.ts +5 -1
  23. package/src/lib/legal/app-privacy.ts +5 -3
  24. package/src/lib/legal/text-en.ts +1 -0
  25. package/src/lib/legal/text-es.ts +1 -0
  26. package/src/lib/live/view.ts +28 -24
  27. package/src/lib/live/words.ts +94 -0
  28. package/src/lib/mobile/bookings.ts +17 -11
  29. package/src/lib/mobile/forms.ts +21 -11
  30. package/src/lib/mobile/manage.ts +2 -0
  31. package/src/lib/mobile/openapi.ts +8 -3
  32. package/src/lib/mobile/rsvp.ts +65 -0
  33. package/src/lib/mobile/schemas.ts +9 -1
  34. package/src/lib/mobile/site.ts +3 -1
  35. package/src/lib/push/api.ts +13 -1
  36. package/src/lib/push/service.ts +211 -24
  37. package/src/lib/push/store.ts +35 -8
  38. package/src/lib/push/types.ts +23 -2
  39. package/src/lib/repo/migrations.ts +6 -0
  40. package/src/lib/schema.ts +2 -1
  41. package/src/lib/search-i18n.ts +41 -4
  42. package/src/lib/site-chrome.ts +8 -0
  43. package/src/lib/site-words.ts +62 -0
  44. package/src/pages/admin/calendar/bookings.astro +3 -1
  45. package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
  46. package/src/styles/mobile-app.css +7 -0
  47. 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(), answers: z.record(z.string().max(2000)).default({}),
21
+ minutes: z.number().int().min(1).max(44640).optional(),
20
22
  }).strict(),
21
- z.object({ ...common, event: z.string().min(1).max(200), seats: z.number().int().min(1).max(1000).default(1), answers: z.record(z.string().max(2000)).default({}), formVersion: z.string().optional() }).strict(),
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(true);
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 event = await repo.getEventBySlug(body.event) ?? await repo.getEvent(body.event);
52
- const calendars = new Map((await repo.listCalendars()).map(c => [c.id, c]));
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
- const occurrence = expandEvents([event], settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: start, windowEndMs: start + 1 }).find(o => o.start === start);
56
- if (!occurrence) throw new ApiError(400, 'Start must match an event occurrence exactly.');
57
- booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker: body, source: 'web' }, flow);
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: body, source: 'web' }, flow);
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) {
@@ -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
- export async function getForm(repo: ContentRepository, url: URL, id: string) {
28
- z.object({}).strict().parse(queryParams(url));
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
- id: form.id, version: await formVersion(d),
34
- title: d.title || form.name, intro: d.intro, submitLabel: d.submitLabel || 'Submit', confirmation: d.confirmation,
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
- // field id → answer; checkbox groups send a list. "Other" answers send the typed text.
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 = Object.fromEntries(Object.entries(body.answers).map(([k, v]) => [k, Array.isArray(v) ? v.map((x) => x.trim()).filter(Boolean).join('\n') : v]));
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
@@ -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]) => ({ name, in: 'query', required: !(schema as z.ZodTypeAny).isOptional(), schema: jsonSchema(schema as z.ZodTypeAny) }));
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; requires bookings',
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() });
@@ -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(), search: SEARCH.enabled,
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,
@@ -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 };