@acms-subir/core 0.3.55 → 0.3.57

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 (73) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +16 -4
  3. package/docs/calendar.md +189 -2
  4. package/docs/mobile.md +3 -1
  5. package/docs/server.md +84 -1
  6. package/integration.mjs +34 -0
  7. package/mobile-template/client/AGENTS.md +1 -0
  8. package/mobile-template/client/README.md +1 -1
  9. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  10. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  11. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  12. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  13. package/mobile-template/recipes/event-request/recipe.json +21 -0
  14. package/package.json +1 -1
  15. package/src/actions/index.ts +52 -9
  16. package/src/components/EventPage.astro +6 -5
  17. package/src/components/MobileApp.tsx +2 -2
  18. package/src/components/calendar/CalendarBoard.tsx +6 -3
  19. package/src/components/editor/event/SingleDates.tsx +87 -17
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/api.ts +23 -2
  22. package/src/lib/calendar/board.ts +3 -2
  23. package/src/lib/calendar/cards.ts +4 -4
  24. package/src/lib/calendar/change-migration.ts +17 -3
  25. package/src/lib/calendar/data.ts +9 -2
  26. package/src/lib/calendar/event-words.ts +8 -0
  27. package/src/lib/calendar/events.ts +128 -15
  28. package/src/lib/calendar/google/map.ts +1 -1
  29. package/src/lib/calendar/host-requests.ts +65 -10
  30. package/src/lib/calendar/ics-write.ts +65 -15
  31. package/src/lib/calendar/member-words.ts +14 -1
  32. package/src/lib/calendar/moves.ts +15 -9
  33. package/src/lib/calendar/request-admin.ts +212 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +42 -8
  41. package/src/lib/calendar/segments.ts +522 -0
  42. package/src/lib/editor/schedule.ts +2 -2
  43. package/src/lib/email/service.ts +6 -1
  44. package/src/lib/legal/app-privacy.ts +2 -2
  45. package/src/lib/legal/facts.ts +4 -0
  46. package/src/lib/legal/text-en.ts +3 -0
  47. package/src/lib/legal/text-es.ts +3 -0
  48. package/src/lib/mobile/calendar.ts +1 -0
  49. package/src/lib/mobile/http.ts +2 -1
  50. package/src/lib/mobile/openapi.ts +8 -0
  51. package/src/lib/mobile/requests.ts +27 -0
  52. package/src/lib/mobile/schemas.ts +24 -6
  53. package/src/lib/mobile/site.ts +3 -0
  54. package/src/lib/push/service.ts +42 -10
  55. package/src/lib/push/types.ts +3 -1
  56. package/src/lib/repo/migrations.ts +5 -2
  57. package/src/lib/repo/sql.ts +24 -8
  58. package/src/lib/repo/types.ts +3 -1
  59. package/src/lib/scheduler.ts +3 -0
  60. package/src/lib/schema.ts +67 -2
  61. package/src/lib/sections.ts +1 -1
  62. package/src/lib/sync/catalog.ts +1 -0
  63. package/src/pages/admin/calendar/events/index.astro +11 -8
  64. package/src/pages/admin/calendar/requests.astro +298 -0
  65. package/src/pages/api/calendar/events/index.ts +2 -1
  66. package/src/pages/api/v1/requests/index.ts +6 -0
  67. package/src/pages/api/v1/requests/options.ts +7 -0
  68. package/src/pages/members/events/index.astro +84 -6
  69. package/src/pages/request/index.astro +182 -0
  70. package/src/server/index.ts +102 -4
  71. package/src/styles/editor.css +2 -0
  72. package/src/styles/site.css +29 -0
  73. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,522 @@
1
+ // "From this date on" (C4): a repeating event changed from one of its dates,
2
+ // with new times, weekday, rule, rooms or place, while the dates before it
3
+ // stay exactly as they were. It stays one event (same page, slug, hosts,
4
+ // RSVPs): the change is a segment on the event row (CalEvent.segments, see
5
+ // events.ts seriesParts), so every write path, trigger and feed sees it.
6
+ //
7
+ // What follows the change point. Each date of the old schedule from `fromKey`
8
+ // on is matched to the new date nearest it, within half a week (3½ days),
9
+ // each new date taking at most one old date (closest pairs first):
10
+ // - its RSVPs move to that date, and each person gets the "new time" email
11
+ // (places in line move quietly); RSVPs on a date with no match are
12
+ // cancelled, with the "cancelled" email;
13
+ // - a cancelled date stays cancelled on its match, a skipped one (exdates)
14
+ // stays skipped, and a moved date keeps its new time under its match's key;
15
+ // - a cancelled, skipped or moved date with no match is dropped (`dropped`).
16
+ // Changing from a date that a segment starts with replaces that segment; a
17
+ // later change replaces every segment after it (Google's "this and
18
+ // following"); a change that gives exactly what the earlier rule gives
19
+ // removes the segment. A cancelled part (cancelFromDate) cancels every date
20
+ // from a date on, and restoreFromDate puts them back.
21
+ //
22
+ // Who: staff ("Create and edit content") and GOOGLE_SYNC. Room clashes come
23
+ // back as warnings. Hosts send it as a request (host-requests.ts), and staff
24
+ // apply it here.
25
+ import type { ContentRepository } from '../repo/types';
26
+ import { EventLocation, type CalEvent, type SeriesSegment } from '../schema';
27
+ import { ApiError, coded } from '../api';
28
+ import { eventSideEffects, type SaveHooks } from '../content-service';
29
+ import { addWallDays, addWallMinutes, cutsAt, eventTimeZone, expandEvents, findOccurrence, formatWall, normalizeEventTimes, parseWall, partOf, seriesParts, wallMinutesBetween, type Occurrence, type SeriesPart } from './events';
30
+ import { cleanRrule, describeRepeat, rruleMap } from './rrule';
31
+ import { siteLocale } from '../dates';
32
+ import { shiftRrule } from './schedule';
33
+ import { GOOGLE_SYNC, type MoveActor } from './moves';
34
+ import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
35
+ import { kickEmail } from '../email/service';
36
+ import { waitUntilFrom } from '../webhooks';
37
+
38
+ export interface ChangeFromInput {
39
+ /** the new part's first date: wall-clock in the event's zone, like `start` (YYYY-MM-DDTHH:mm, or YYYY-MM-DD all day); may come before `fromKey` */
40
+ start: string;
41
+ /** its end in the same form (all-day: the last day, inclusive); default: the length the dates have now */
42
+ end?: string;
43
+ /** its rule (RRULE without the prefix; '' = just that date); default: the rule in effect, moved to the new start's weekday or day, its COUNT what's left */
44
+ rrule?: string;
45
+ /** its rooms (Resource ids); default: the rooms in effect at `fromKey` */
46
+ roomIds?: string[];
47
+ /** its place; null = the event's; default: the place in effect at `fromKey` */
48
+ location?: Partial<EventLocation> | null;
49
+ /** a public note ("We meet on Wednesdays from January") */
50
+ note?: string;
51
+ }
52
+ export interface SegmentOptions {
53
+ /** no member push for it (a sync catching up); RSVP emails still go */
54
+ quiet?: boolean;
55
+ now?: number;
56
+ }
57
+ export interface ChangeFromResult {
58
+ event: CalEvent;
59
+ /** what else takes the new dates' rooms over the next 180 days (warnings) */
60
+ clashes: BusySlot[];
61
+ /** false: it already was so; nothing was written, sent or emailed */
62
+ changed: boolean;
63
+ /** RSVPs moved to their new date, and cancelled for want of one */
64
+ rsvps: { moved: number; cancelled: number };
65
+ /** old cancelled, skipped or moved dates (keys) dropped for want of a new date */
66
+ dropped: string[];
67
+ }
68
+
69
+ /** Why a change was refused (error.code, with error.status): as for moves, plus previous-date (the new first date isn't after the date before the change), first-date (the change is from the series' first date: edit the event instead), bad-rule, host-request (a host: send it as a request), not-cancelled. */
70
+ export type SegmentRefusal = 'one-off' | 'cancelled-event' | 'not-a-date' | 'started' | 'past' | 'bad-time' | 'previous-date' | 'first-date' | 'bad-rule' | 'host-request' | 'not-cancelled';
71
+ const refuse = (status: number, code: SegmentRefusal, message: string) => coded(status, code, message);
72
+
73
+ const DAY = 86_400_000;
74
+ const NOTE_MAX = 500;
75
+ /** how far a date's sign-ups, cancellation or move may travel to its new date */
76
+ export const FOLLOW_WITHIN = 3.5 * DAY;
77
+ // how far ahead old and new dates are matched, and clashes looked for
78
+ const HORIZON = 2 * 366 * DAY;
79
+ const CLASH_DAYS = 180;
80
+
81
+ type Settings = Awaited<ReturnType<ContentRepository['getSettings']>>;
82
+
83
+ function requireStaff(actor: MoveActor | null, e: CalEvent) {
84
+ if (actor === GOOGLE_SYNC) return;
85
+ if (!actor) throw coded(401, 'sign-in', 'Sign in to continue.');
86
+ if (actor.can('content.edit')) return;
87
+ if (e.hostIds.includes(actor.id)) throw refuse(403, 'host-request', 'Send this change to the team as a request. They apply changes from a date on.');
88
+ throw coded(404, 'not-found', 'Event not found.');
89
+ }
90
+ const actorId = (actor: MoveActor) => (actor === GOOGLE_SYNC ? GOOGLE_SYNC : actor.id);
91
+
92
+ async function load(repo: ContentRepository, id: string) {
93
+ const e = await repo.getEvent(id);
94
+ if (!e || e.status === 'archived') throw coded(404, 'not-found', 'Event not found.');
95
+ return e;
96
+ }
97
+
98
+ // every date of the series as the rules make them, moves, cancellations and skips aside
99
+ const bare = (e: CalEvent): CalEvent => ({ ...e, movedDates: {}, cancelledDates: [], exdates: [] });
100
+ const datesFrom = (e: CalEvent, settings: Settings, from: number, to = from + HORIZON) =>
101
+ expandEvents([bare(e)], settings, { displayTz: settings.timezone || 'UTC', now: from, windowStartMs: from, windowEndMs: to });
102
+
103
+ // The date at `key` and the part of the series it's in, checked for
104
+ // changing. A key a segment took over from (no longer a date itself: a retry,
105
+ // or a change of that segment) means that segment, at the date the series
106
+ // had there. `replaces`: the change takes that part's place rather than
107
+ // starting inside it (it has no dates before `key`).
108
+ function changePoint(e: CalEvent, key: string, settings: Settings, now: number) {
109
+ if (!e.rrule) throw refuse(400, 'one-off', 'Only a repeating event can change from a date on. Change the event’s time instead.');
110
+ if (e.cancelled) throw refuse(409, 'cancelled-event', 'This event is cancelled.');
111
+ const valid = typeof key === 'string' && key.length <= 20;
112
+ let at = valid ? findOccurrence(bare(e), key, settings) : null;
113
+ let part = at ? partOf(e, at) : null;
114
+ if (!at && valid && e.segments.some((g) => g.from === key)) {
115
+ at = findOccurrence(bare({ ...e, segments: e.segments.filter((g) => g.from < key) }), key, settings);
116
+ part = seriesParts(e).find((p) => p.from === key) ?? null;
117
+ }
118
+ if (!at || !part) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
119
+ if (at.start <= now) throw refuse(400, 'started', 'That date has already started or passed. Pick a date still to come.');
120
+ // the dates before it, nearest last (the new first date must come after them)
121
+ const before = expandEvents([bare(e)], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: at.start - 400 * DAY, windowEndMs: at.start + FOLLOW_WITHIN })
122
+ .filter((o) => o.key < key);
123
+ const mine = (o: Occurrence) => (o.segment ?? '') === part!.from;
124
+ const replaces = Boolean(part.from) && (key === part.from || !before.some(mine));
125
+ const previous = (replaces ? before.filter((o) => !mine(o)) : before).at(-1) ?? null;
126
+ // the part's date the change starts from, for its rule's default: a replaced segment's own first date
127
+ const anchor = (replaces && findOccurrence(bare(e), part.start, settings)) || at;
128
+ // a replaced segment whose first date (maybe before `key`) has started can't change any more
129
+ const started = replaces && anchor.start <= now;
130
+ return { at, part, previous, replaces, anchor, started };
131
+ }
132
+
133
+ // a part's dates as a rule of its own, anchored at the date `at` (a cancelled part, or the default rule for a change)
134
+ function ruleFrom(e: CalEvent, part: SeriesPart, at: Occurrence, newStart: string, settings: Settings): string {
135
+ const rule = cleanRrule(part.rrule);
136
+ if (!rule) return '';
137
+ const p = rruleMap(rule);
138
+ const from = parseWall(at.key), to = parseWall(newStart);
139
+ const days = from && to ? Math.round(wallMinutesBetween({ ...from, hh: 0, mm: 0 }, { ...to, hh: 0, mm: 0 }) / 1440) : 0;
140
+ // the weekdays (or day of the month) follow the new start; the end date stays
141
+ let out = shiftRrule(rule, days, newStart);
142
+ if (p.UNTIL) out = out.replace(/UNTIL=[^;]*/, `UNTIL=${p.UNTIL}`);
143
+ if (p.COUNT) {
144
+ const solo: CalEvent = { ...bare(e), start: part.start, end: part.end, rrule: rule, segments: [] };
145
+ const left = expandEvents([solo], settings, { displayTz: settings.timezone || 'UTC', now: at.start, windowStartMs: at.start, windowEndMs: at.start + 10 * 366 * DAY }).length;
146
+ out = out.replace(/COUNT=\d+/, `COUNT=${Math.max(1, left)}`);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ // the new first date's times in the event's form; `end` defaults to the length the dates have now
152
+ function times(e: CalEvent, part: SeriesPart, input: Pick<ChangeFromInput, 'start' | 'end'>): { start: string; end: string } {
153
+ const s = parseWall(input.start);
154
+ if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
155
+ if (s.dateOnly !== e.allDay) throw refuse(400, 'bad-time', e.allDay ? 'This event lasts all day: give a date only (YYYY-MM-DD).' : 'Give a date and a time (YYYY-MM-DDTHH:mm).');
156
+ let end = input.end;
157
+ if (!end) {
158
+ const ps = parseWall(part.start), pe = parseWall(part.end) ?? ps;
159
+ const length = ps && pe ? wallMinutesBetween(ps, pe) : 60;
160
+ end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
161
+ }
162
+ try { return normalizeEventTimes({ start: input.start, end, allDay: e.allDay }); }
163
+ catch (err) { throw refuse(400, 'bad-time', err instanceof Error ? err.message : 'Pick a valid time.'); }
164
+ }
165
+
166
+ const sameRooms = (a: string[], b: string[]) => a.length === b.length && [...a].sort().every((x, i) => x === [...b].sort()[i]);
167
+ const sameDates = (a: Occurrence[], b: Occurrence[]) => a.length === b.length && a.every((o, i) => o.key === b[i].key && o.start === b[i].start && o.end === b[i].end);
168
+
169
+ // A segment that gives exactly what the part before it would (same dates over
170
+ // the horizon, rooms, place, not cancelled) isn't a change: drop it. Each is
171
+ // checked against the parts kept before it.
172
+ function normalize(e: CalEvent, segments: SeriesSegment[], settings: Settings, now: number): SeriesSegment[] {
173
+ const kept: SeriesSegment[] = [];
174
+ const sorted = [...segments].sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
175
+ for (let i = 0; i < sorted.length; i++) {
176
+ const g = sorted[i];
177
+ const next = sorted[i + 1]?.from ?? '';
178
+ const withIt = { ...e, segments: [...kept, g, ...(next ? [sorted[i + 1]] : [])] };
179
+ const without = { ...e, segments: [...kept, ...(next ? [sorted[i + 1]] : [])] };
180
+ const prev = partOf(without, { segment: kept[kept.length - 1]?.from });
181
+ const mine = seriesParts(withIt).find((p) => p.from === g.from)!;
182
+ // quick no: cancelled, other rooms or place, or a first date the earlier rule doesn't have there
183
+ const firstThen = findOccurrence(bare(without), g.start, settings);
184
+ const firstNow = findOccurrence(bare(withIt), g.start, settings);
185
+ const maybe = !g.cancelled && !prev.cancelled && sameRooms(mine.roomIds, prev.roomIds) && JSON.stringify(mine.location ?? null) === JSON.stringify(prev.location ?? null)
186
+ && g.from === g.start && firstThen && firstNow && (firstThen.segment ?? '') === prev.from && firstThen.start === firstNow.start && firstThen.end === firstNow.end;
187
+ if (!maybe) { kept.push(g); continue; }
188
+ // then the dates themselves, over the next 400 days or to the later end date of the two rules
189
+ const span = Math.min(10 * 366 * DAY, Math.max(400 * DAY, untilMs(g.rrule) - firstNow.start, untilMs(prev.rrule) - firstNow.start) + 2 * DAY);
190
+ const a = datesFrom(withIt, settings, firstNow.start - DAY, firstNow.start + span).filter((o) => o.segment === g.from);
191
+ const b = datesFrom(without, settings, firstNow.start - DAY, firstNow.start + span).filter((o) => (o.segment ?? '') === prev.from && cutsAt(g.from, o.key));
192
+ if (!sameDates(a, b)) kept.push(g);
193
+ }
194
+ return kept;
195
+ }
196
+
197
+ // a date's series times as wall-clock "start|end" (to compare with a MovedDate)
198
+ function formatWallAt(o: Occurrence, e: CalEvent, _settings: Settings): string {
199
+ const w = parseWall(o.key);
200
+ if (!w) return '';
201
+ const part = partOf(e, o);
202
+ const ps = parseWall(part.start), pe = parseWall(part.end) ?? ps;
203
+ const length = ps && pe ? wallMinutesBetween(ps, pe) : 0;
204
+ return `${o.key}|${formatWall(e.allDay ? addWallDays(w, Math.round(length / 1440)) : addWallMinutes(w, length))}`;
205
+ }
206
+
207
+ // a rule's UNTIL as a time (roughly: the day's end in UTC), 0 when it has none
208
+ function untilMs(rrule: string): number {
209
+ const u = rruleMap(rrule).UNTIL;
210
+ return u ? Date.UTC(+u.slice(0, 4), +u.slice(4, 6) - 1, +u.slice(6, 8)) + DAY : 0;
211
+ }
212
+
213
+ // old dates (from the change point on) → new dates: nearest within FOLLOW_WITHIN, closest pairs first, one each
214
+ function matchDates(old: Occurrence[], fresh: Occurrence[]): Map<string, Occurrence> {
215
+ const pairs: { o: Occurrence; n: Occurrence; d: number }[] = [];
216
+ for (const o of old) for (const n of fresh) {
217
+ const d = Math.abs(n.start - o.start);
218
+ if (d <= FOLLOW_WITHIN) pairs.push({ o, n, d });
219
+ }
220
+ pairs.sort((x, y) => x.d - y.d || x.o.start - y.o.start);
221
+ const out = new Map<string, Occurrence>();
222
+ const taken = new Set<string>();
223
+ for (const { o, n } of pairs) {
224
+ if (out.has(o.key) || taken.has(n.key)) continue;
225
+ out.set(o.key, n);
226
+ taken.add(n.key);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ // what else takes the new part's rooms over the next CLASH_DAYS (each room checked once over the span)
232
+ async function clashesFor(repo: ContentRepository, next: CalEvent, from: string, settings: Settings, start: number): Promise<BusySlot[]> {
233
+ const resources = new Map((await repo.listResources()).map((r) => [r.id, r]));
234
+ const to = start + CLASH_DAYS * DAY;
235
+ const mine = new Set(expandEvents([next], settings, { displayTz: settings.timezone || 'UTC', now: start, windowStartMs: start - DAY, windowEndMs: to + DAY })
236
+ .filter((o) => o.segment === from).map((o) => o.key));
237
+ const holds = holdsFromEvents([next], resources, settings, start, to, { force: true }).filter((h) => mine.has(h.key));
238
+ if (!holds.length) return [];
239
+ const out: BusySlot[] = [];
240
+ const seen = new Set<string>();
241
+ for (const room of [...new Set(holds.map((h) => h.resourceId))]) {
242
+ const busy = await roomBusy(repo, settings, { resourceIds: [room], from: start, to, ignore: { eventId: next.id } });
243
+ for (const b of busy) {
244
+ if (!holds.some((h) => h.resourceId === room && h.start < b.end && b.start < h.end)) continue;
245
+ const id = `${b.resourceId}:${b.kind}:${b.bookingId ?? `${b.eventId}:${b.key}`}:${b.start}`;
246
+ if (!seen.has(id)) { seen.add(id); out.push(b); }
247
+ }
248
+ }
249
+ return out.sort((a, b) => a.start - b.start);
250
+ }
251
+
252
+ /**
253
+ * Rewrites the series from `fromKey` with `segments` (the earlier ones kept),
254
+ * carrying cancelled, skipped and moved dates and RSVPs over to their new
255
+ * dates (see the top of this file), in one write.
256
+ */
257
+ async function apply(repo: ContentRepository, e: CalEvent, fromKey: string, at: Occurrence, segments: SeriesSegment[], note: string, hooks: SaveHooks, settings: Settings, opts: SegmentOptions, cancelling: boolean) {
258
+ const now = opts.now ?? Date.now();
259
+ const next: CalEvent = { ...e, segments: normalize(e, segments, settings, now) };
260
+ // how far to look: past the last date anything is attached to (an RSVP, a cancelled, moved or skipped date), at least five weeks
261
+ const bookings = await repo.listBookings({ eventId: e.id, kinds: ['rsvp'], from: Math.min(at.start, now) - 4 * DAY, statuses: ['pending', 'confirmed', 'offered', 'waitlisted'] });
262
+ const signedUp = new Set(bookings.map((b) => b.start));
263
+ const roughly = (key: string) => { const ms = Date.parse(`${key.length === 10 ? `${key}T00:00` : key}:00Z`); return Number.isFinite(ms) ? ms + 2 * DAY : 0; };
264
+ const last = Math.max(0, ...bookings.map((b) => b.end), ...[...e.cancelledDates, ...e.exdates, ...Object.keys(e.movedDates)].map(roughly),
265
+ ...Object.values(e.movedDates).map((m) => roughly(m.start)));
266
+ const startAt = Math.min(at.start, now) - 4 * DAY;
267
+ const endAt = Math.min(at.start + HORIZON, Math.max(at.start + 35 * DAY, last + 7 * DAY));
268
+ const span = (x: CalEvent) => datesFrom(x, settings, startAt, endAt);
269
+ // the old dates the change takes over (from `fromKey` on, and those of the segments it replaces), and the dates the new schedule makes there
270
+ const taken = (o: Occurrence) => cutsAt(fromKey, o.key) || (o.segment !== undefined && o.segment >= fromKey);
271
+ const all = span(e);
272
+ const old = all.filter(taken);
273
+ const oldKeys = new Set(old.map((o) => o.key));
274
+ const keepBefore = new Set(all.filter((o) => !taken(o)).map((o) => o.key));
275
+ const fresh = span(next).filter((o) => !keepBefore.has(o.key));
276
+ const match = matchDates(old, fresh);
277
+ const dropped: string[] = [];
278
+ const follow = (key: string) => (oldKeys.has(key) ? match.get(key)?.key ?? null : key);
279
+ // a cancelled, skipped or moved date carried over, or listed as dropped
280
+ const carry = (key: string) => { const k = follow(key); if (k === null) dropped.push(key); return k; };
281
+ const cancelledDates = [...new Set(e.cancelledDates.map(carry).filter((k): k is string => k !== null))].sort();
282
+ const exdates = [...new Set(e.exdates.map(carry).filter((k): k is string => k !== null))];
283
+ const movedDates: CalEvent['movedDates'] = {};
284
+ for (const [key, m] of Object.entries(e.movedDates)) {
285
+ // cancelling from a date on drops the moves there (a date is moved or cancelled, never both)
286
+ if (cancelling && oldKeys.has(key)) { dropped.push(key); continue; }
287
+ const k = carry(key);
288
+ if (k === null || cancelledDates.includes(k)) continue;
289
+ // a move to where its new date already is, in its rooms and place, isn't one any more
290
+ const there = findOccurrence({ ...next, movedDates: {}, cancelledDates: [], exdates: [] }, k, settings);
291
+ const plain = there && !m.roomIds && !m.location && formatWallAt(there, next, settings) === `${m.start}|${m.end}`;
292
+ if (!plain) movedDates[k] = m;
293
+ }
294
+ const written: CalEvent = { ...next, cancelledDates, exdates, movedDates };
295
+ const changed = JSON.stringify([written.segments, cancelledDates, exdates, movedDates]) !== JSON.stringify([e.segments, e.cancelledDates, e.exdates, e.movedDates]);
296
+ if (!changed) return { written: e, changed, dropped: [], plan: [] as { from: number; to: Occurrence | null }[] };
297
+
298
+ // RSVPs: each old date's (where it is now: a moved one keeps its time) to its match, else cancelled
299
+ const plan: { from: number; to: Occurrence | null }[] = [];
300
+ for (const o of old) {
301
+ const nowAt = findOccurrence(e, o.key, settings);
302
+ if (nowAt && !signedUp.has(nowAt.start)) continue;
303
+ if (!nowAt || nowAt.cancelled) continue;
304
+ const k = follow(o.key);
305
+ const to = k === null ? null : findOccurrence(written, k, settings);
306
+ if (to && !to.cancelled && to.start === nowAt.start && to.end === nowAt.end) continue;
307
+ plan.push({ from: nowAt.start, to: to && !to.cancelled ? to : null });
308
+ }
309
+ await repo.setEventDates(e.id, { segments: written.segments, cancelledDates, exdates, movedDates }, { quiet: opts.quiet });
310
+ return { written, changed, dropped: [...new Set(dropped)].sort(), plan };
311
+ }
312
+
313
+ async function afterApply(repo: ContentRepository, id: string, plan: { from: number; to: Occurrence | null }[], note: string, hooks: SaveHooks) {
314
+ let moved = 0, cancelled = 0;
315
+ for (const p of plan) {
316
+ if (p.to) moved += await repo.moveOccurrenceRsvps(id, p.from, { start: p.to.start, end: p.to.end }, note);
317
+ else cancelled += await repo.cancelOccurrenceRsvps(id, p.from);
318
+ }
319
+ if (moved || cancelled) await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
320
+ const after = (await repo.getEvent(id))!;
321
+ await eventSideEffects(repo, hooks, after);
322
+ return { after, rsvps: { moved, cancelled } };
323
+ }
324
+
325
+ /**
326
+ * Change a repeating event from one of its dates on: new times, weekday,
327
+ * rule, rooms or place, keeping every date before it as it was. See the top
328
+ * of this file for what follows the change. Staff and GOOGLE_SYNC only; room
329
+ * clashes come back as warnings. Idempotent (`changed: false`).
330
+ */
331
+ export async function changeFromDate(repo: ContentRepository, actor: MoveActor | null, id: string, fromKey: string, input: ChangeFromInput, hooks: SaveHooks, opts: SegmentOptions = {}): Promise<ChangeFromResult> {
332
+ const now = opts.now ?? Date.now();
333
+ const e = await load(repo, id);
334
+ requireStaff(actor, e);
335
+ const settings = await repo.getSettings();
336
+ const { from, at, segment, first, same } = await planChange(repo, e, settings, actor!, fromKey, input, now);
337
+ // a retry of what's there already (whoever made it, whenever) changes nothing
338
+ if (same && !e.segments.some((g) => g.from > from)) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
339
+ const segments = [...e.segments.filter((g) => g.from < from), segment];
340
+ const r = await apply(repo, e, from, at, segments, segment.note, hooks, settings, opts, false);
341
+ if (!r.changed) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
342
+ const done = await afterApply(repo, id, r.plan, segment.note, hooks);
343
+ const clashes = r.written.segments.some((g) => g.from === from) ? await clashesFor(repo, done.after, from, settings, Math.max(now, first.start - DAY)) : [];
344
+ return { event: done.after, clashes, changed: true, rsvps: done.rsvps, dropped: r.dropped };
345
+ }
346
+
347
+ /**
348
+ * What changeFromDate would write, checked but not written (a host's request
349
+ * is checked this way before it goes to staff): the same refusals, without
350
+ * the staff check. `segment` is the new part, normalized.
351
+ */
352
+ export async function checkChangeFromDate(repo: ContentRepository, actor: MoveActor, id: string, fromKey: string, input: ChangeFromInput, opts: Pick<SegmentOptions, 'now'> = {}): Promise<{ segment: SeriesSegment; changed: boolean }> {
353
+ const now = opts.now ?? Date.now();
354
+ const e = await load(repo, id);
355
+ const settings = await repo.getSettings();
356
+ const { from, segment, same } = await planChange(repo, e, settings, actor, fromKey, input, now);
357
+ if (same && !e.segments.some((g) => g.from > from)) return { segment, changed: false };
358
+ const next = normalize(e, [...e.segments.filter((g) => g.from < from), segment], settings, now);
359
+ return { segment, changed: JSON.stringify(next.map(withoutWho)) !== JSON.stringify(e.segments.map(withoutWho)) };
360
+ }
361
+
362
+ async function planChange(repo: ContentRepository, e: CalEvent, settings: Settings, actor: MoveActor, fromKey: string, input: ChangeFromInput, now: number) {
363
+ const { at, part, previous, replaces, anchor, started } = changePoint(e, fromKey, settings, now);
364
+ if (!previous) throw refuse(400, 'first-date', 'That’s the first date of the series. Change the event’s own time and repeat instead.');
365
+ const t = times(e, part, input);
366
+ const rrule = input.rrule === undefined ? ruleFrom(e, part, anchor, t.start, settings) : cleanRrule(String(input.rrule));
367
+ if (input.rrule !== undefined && String(input.rrule).trim() && !rrule) throw refuse(400, 'bad-rule', 'That repeat rule isn’t one the calendar understands.');
368
+
369
+ const resources = await repo.listResources();
370
+ let roomIds = input.roomIds === undefined ? undefined : [...new Set(input.roomIds)].filter((r) => resources.some((x) => x.id === r));
371
+ if (roomIds && sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
372
+ if (input.roomIds === undefined && part.from) roomIds = sameRooms(part.roomIds, e.roomHold.resourceIds) ? undefined : part.roomIds;
373
+ let location = input.location === undefined ? part.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
374
+ if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
375
+ if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
376
+ const note = String(input.note ?? '').replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
377
+
378
+ // a change from the date a segment starts with replaces that segment (keeping where it took over)
379
+ const from = replaces ? part.from : fromKey;
380
+ const fresh: SeriesSegment = { from, start: t.start, end: t.end, rrule, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), cancelled: false, note, by: actorId(actor), at: now };
381
+ // the same change as the one there: keep that one (its by/at) as it is
382
+ const existing = e.segments.find((g) => g.from === from);
383
+ const same = Boolean(existing && JSON.stringify(withoutWho(existing)) === JSON.stringify(withoutWho(fresh)));
384
+ // the first date has to be one of the new rule's own dates (its DTSTART); a retry of what's there skips the time checks
385
+ const first = findOccurrence({ ...bare(e), start: t.start, end: t.end, rrule, segments: [] }, t.start, settings);
386
+ if (!first) throw refuse(400, 'bad-rule', 'The first date has to be one of the new pattern’s dates.');
387
+ if (!same) {
388
+ if (started) throw refuse(400, 'started', 'That change has already started. Change from a date still to come instead.');
389
+ if (first.start <= now) throw refuse(400, 'past', 'Pick a first date that hasn’t passed yet.');
390
+ if (first.start <= previous.start) throw refuse(400, 'previous-date', 'Pick a first date after the date before the change.');
391
+ }
392
+ return { from, at, segment: same ? existing! : fresh, first, same };
393
+ }
394
+
395
+ // a segment as what it does, not who made it when
396
+ const withoutWho = ({ by: _by, at: _at, ...g }: SeriesSegment) => ({ ...g, roomIds: g.roomIds ?? null, location: g.location ?? null });
397
+
398
+ /** Cancel every date of a repeating event from one on: they stay listed, struck through, hold no room, and their RSVPs are cancelled (emailed). Idempotent. */
399
+ export async function cancelFromDate(repo: ContentRepository, actor: MoveActor | null, id: string, fromKey: string, hooks: SaveHooks, opts: SegmentOptions = {}): Promise<ChangeFromResult> {
400
+ const now = opts.now ?? Date.now();
401
+ const e = await load(repo, id);
402
+ requireStaff(actor, e);
403
+ const settings = await repo.getSettings();
404
+ const { at, part, previous, replaces } = changePoint(e, fromKey, settings, now);
405
+ if (part.cancelled) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
406
+ if (!previous) throw refuse(400, 'first-date', 'That’s the first date of the series. Cancel the whole event instead.');
407
+ const from = replaces ? part.from : fromKey;
408
+ const startWall = at.key;
409
+ const t = times(e, part, { start: startWall });
410
+ const own = e.segments.find((g) => g.from === from);
411
+ const segment: SeriesSegment = own && from === part.from
412
+ ? { ...own, cancelled: true, by: actorId(actor!), at: now }
413
+ : { from, start: t.start, end: t.end, rrule: ruleFrom(e, part, at, t.start, settings), ...(part.from && !sameRooms(part.roomIds, e.roomHold.resourceIds) ? { roomIds: part.roomIds } : {}),
414
+ ...(part.location ? { location: part.location } : {}), cancelled: true, note: '', by: actorId(actor!), at: now };
415
+ // every date from there on: later segments go too, and the rule in effect carries on, struck through
416
+ const r = await apply(repo, e, from, at, [...e.segments.filter((g) => g.from < from), segment], '', hooks, settings, opts, true);
417
+ if (!r.changed) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
418
+ const done = await afterApply(repo, id, r.plan, '', hooks);
419
+ return { event: done.after, clashes: [], changed: true, rsvps: done.rsvps, dropped: r.dropped };
420
+ }
421
+
422
+ /** Undo cancelFromDate (at the date it took effect, or any date it covers): the dates are back on; their RSVPs stay cancelled, so people sign up again. Idempotent. */
423
+ export async function restoreFromDate(repo: ContentRepository, actor: MoveActor | null, id: string, fromKey: string, hooks: SaveHooks, opts: SegmentOptions = {}): Promise<ChangeFromResult> {
424
+ const now = opts.now ?? Date.now();
425
+ const e = await load(repo, id);
426
+ requireStaff(actor, e);
427
+ const settings = await repo.getSettings();
428
+ // a date the cancelled part has, or the key it took over from
429
+ const o = findOccurrence(bare(e), fromKey, settings);
430
+ const part = o ? partOf(e, o) : seriesParts(e).find((p) => p.from === fromKey) ?? null;
431
+ if (!part?.cancelled) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
432
+ const first = findOccurrence(bare(e), part.start, settings);
433
+ if (!first || first.start <= now) throw refuse(400, 'started', 'Those dates have already started. Change from a date still to come instead.');
434
+ const segments = e.segments.map((g) => (g.from === part.from ? { ...g, cancelled: false, by: actorId(actor!), at: now } : g));
435
+ const at = findOccurrence(bare({ ...e, segments: e.segments.filter((g) => g.from < part.from) }), part.from, settings) ?? first;
436
+ const r = await apply(repo, e, part.from, at, segments, '', hooks, settings, opts, false);
437
+ if (!r.changed) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
438
+ // nothing to move: the dates come back where they were
439
+ const done = await afterApply(repo, id, [], '', hooks);
440
+ return { event: done.after, clashes: [], changed: true, rsvps: done.rsvps, dropped: r.dropped };
441
+ }
442
+
443
+ /**
444
+ * Undo a change from a date on: the segment taking over at `from` goes, and
445
+ * the rule before it carries on (to the next segment). Its dates' RSVPs,
446
+ * cancelled and moved dates follow back the same way. Idempotent.
447
+ */
448
+ export async function revertFromDate(repo: ContentRepository, actor: MoveActor | null, id: string, from: string, hooks: SaveHooks, opts: SegmentOptions = {}): Promise<ChangeFromResult> {
449
+ return setSegments(repo, actor, id, from, (segments) => segments.filter((g) => g.from !== from), hooks, opts);
450
+ }
451
+
452
+ /** Put an event's segments back to `previous` (a Schedule undo), carrying dates over from `from` on. */
453
+ export async function restoreSegments(repo: ContentRepository, actor: MoveActor | null, id: string, from: string, previous: SeriesSegment[], hooks: SaveHooks, opts: SegmentOptions = {}): Promise<ChangeFromResult> {
454
+ return setSegments(repo, actor, id, from, () => previous.map((g) => SeriesSegmentParse(g)), hooks, opts);
455
+ }
456
+ const SeriesSegmentParse = (g: SeriesSegment): SeriesSegment => ({ ...g, note: g.note ?? '', by: g.by ?? '', at: g.at ?? 0, cancelled: Boolean(g.cancelled) });
457
+
458
+ async function setSegments(repo: ContentRepository, actor: MoveActor | null, id: string, from: string, change: (segments: SeriesSegment[]) => SeriesSegment[], hooks: SaveHooks, opts: SegmentOptions) {
459
+ const now = opts.now ?? Date.now();
460
+ const e = await load(repo, id);
461
+ requireStaff(actor, e);
462
+ const settings = await repo.getSettings();
463
+ const segments = change(e.segments);
464
+ if (JSON.stringify(segments) === JSON.stringify(e.segments)) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
465
+ // the date the change point stands for: the series' date there without the segments from it on
466
+ const at = findOccurrence(bare({ ...e, segments: e.segments.filter((g) => g.from < from) }), from, settings);
467
+ if (!at) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
468
+ if (at.start <= now) throw refuse(400, 'started', 'That date has already started or passed.');
469
+ // nor may a change that goes or changes have started (its first date can come before `from`)
470
+ const gone = e.segments.filter((g) => g.from >= from && !segments.some((x) => JSON.stringify(x) === JSON.stringify(g)));
471
+ if (gone.some((g) => (findOccurrence(bare(e), g.start, settings)?.start ?? Infinity) <= now)) throw refuse(400, 'started', 'That change has already started. Change from a date still to come instead.');
472
+ const r = await apply(repo, e, from, at, segments, '', hooks, settings, opts, false);
473
+ if (!r.changed) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
474
+ const done = await afterApply(repo, id, r.plan, '', hooks);
475
+ return { event: done.after, clashes: [], changed: true, rsvps: done.rsvps, dropped: r.dropped };
476
+ }
477
+
478
+ /** The default rule and times a change from `fromKey` would start with (for forms): the date's own times and the rule in effect there. */
479
+ export async function changeFromDefaults(repo: ContentRepository, id: string, fromKey: string): Promise<{ start: string; end: string; rrule: string; roomIds: string[]; location: EventLocation | null } | null> {
480
+ const e = await repo.getEvent(id);
481
+ if (!e?.rrule) return null;
482
+ const settings = await repo.getSettings();
483
+ const at = findOccurrence(bare(e), fromKey, settings);
484
+ if (!at) return null;
485
+ const part = partOf(e, at);
486
+ const t = times(e, part, { start: at.key });
487
+ return { start: t.start, end: t.end, rrule: ruleFrom(e, part, at, t.start, settings), roomIds: part.roomIds, location: part.location ?? null };
488
+ }
489
+
490
+ /** A change from a date on, for the editor's Single dates panel: when it took over, the rule in words with its times, rooms and state. */
491
+ export interface SegmentRow {
492
+ from: string;
493
+ /** its first date: "Wed, Jan 16" */
494
+ when: string;
495
+ /** "Weekly on Wednesday · 5:00 – 6:00 PM" */
496
+ repeats: string;
497
+ rooms: string[];
498
+ cancelled: boolean;
499
+ note: string;
500
+ /** it has taken over already */
501
+ current: boolean;
502
+ }
503
+
504
+ /** The changes from a date on that still have dates to come, in order. */
505
+ export function upcomingSegments(e: CalEvent, settings: Settings, roomNames: Map<string, string>, now = Date.now()): SegmentRow[] {
506
+ const parts = seriesParts(e).slice(1);
507
+ if (!parts.length) return [];
508
+ const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
509
+ const fmt = (o: Intl.DateTimeFormatOptions) => { try { return new Intl.DateTimeFormat(siteLocale(settings), { ...o, timeZone: tz }); } catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); } };
510
+ const day = fmt({ weekday: 'short', month: 'short', day: 'numeric' }), time = fmt({ hour: 'numeric', minute: '2-digit' });
511
+ const dates = expandEvents([bare(e)], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now - 400 * DAY, windowEndMs: now + 400 * DAY });
512
+ return parts.flatMap((p) => {
513
+ const mine = dates.filter((o) => o.segment === p.from);
514
+ if (!mine.length || mine[mine.length - 1].end <= now) return [];
515
+ const first = mine[0];
516
+ const range = (() => { try { return time.formatRange(first.start, first.end); } catch { return `${time.format(first.start)} – ${time.format(first.end)}`; } })();
517
+ return [{
518
+ from: p.from, when: day.format(first.start), repeats: [describeRepeat(p.rrule, p.start), e.allDay ? '' : range].filter(Boolean).join(' · '),
519
+ rooms: p.roomIds.map((id) => roomNames.get(id)).filter((n): n is string => Boolean(n)), cancelled: p.cancelled, note: p.note, current: first.start <= now,
520
+ }];
521
+ });
522
+ }
@@ -5,7 +5,7 @@ import { ApiError } from '../api';
5
5
  import { ApprovalMode, ApprovalRules, BookingMode, DateOverride, QUESTION_TYPES, TimeRange, WeeklyHours, slugify } from '../schema';
6
6
  import { CALENDAR, bookingsEnabled } from '../calendar/config';
7
7
  import { collectViewEvents } from '../calendar/data';
8
- import { isValidTimeZone, safeColor } from '../calendar/events';
8
+ import { isValidTimeZone, partOf, safeColor } from '../calendar/events';
9
9
  import { describeRepeat } from '../calendar/rrule';
10
10
  import { zonedToUtc } from '../calendar/ics';
11
11
  import { normalizeCalendarSrc } from '../calendar/src';
@@ -59,7 +59,7 @@ export async function schedule(c: EditorContext, q: z.infer<typeof ScheduleQuery
59
59
  kind: e ? 'event' as const : 'feed' as const, id: e?.id ?? '', title: v.title,
60
60
  start: iso(v.start), end: iso(v.end), allDay: v.allDay, status: e ? e.status : 'feed',
61
61
  calendarId: cal?.id ?? '', color: v.color ?? (cal ? safeColor(cal.color) : '#8a8378'),
62
- location: v.location, cancelled: Boolean(v.cancelled), repeat: e?.rrule ? describeRepeat(e.rrule, e.start) : '',
62
+ location: v.location, cancelled: Boolean(v.cancelled), repeat: e?.rrule ? ((p) => describeRepeat(p.rrule, p.start))(partOf(e, { segment: v.segment })) : '',
63
63
  resourceIds: e?.roomHold.resourceIds ?? [],
64
64
  };
65
65
  }),
@@ -39,6 +39,11 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
39
39
  return { ...mail, to: job.recipient };
40
40
  }
41
41
  if (job.kind === 'form-alert') return formAlertMail(repo, job.recipient, JSON.parse(job.payload) as AlertPayload, origin);
42
+ // event requests without an account (lib/calendar/request-service.ts)
43
+ if (job.kind === 'calendar-request') {
44
+ const { requestMail } = await import('../calendar/request-mail');
45
+ return requestMail(repo, job.recipient, JSON.parse(job.payload), origin);
46
+ }
42
47
  if (job.kind === 'auth') {
43
48
  const payload = JSON.parse(job.payload);
44
49
  const token = await repo.email.token(payload.tokenId);
@@ -109,7 +114,7 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
109
114
  }
110
115
  await repo.email.pin(j,resolved.provider,resolved.fingerprint,config?.version || 0);
111
116
  if (await repo.email.suppressed(j.recipient)) { await repo.email.finish(j,{state:'suppressed',error:'Recipient is suppressed after a bounce or complaint.'}); continue; }
112
- if (j.kind === 'booking' || j.kind === 'event-request') {
117
+ if (j.kind === 'booking' || j.kind === 'event-request' || j.kind === 'calendar-request') {
113
118
  const payload = JSON.parse(j.payload);
114
119
  if ((payload.kind === 'reminder' && !current.config.reminders) || (String(payload.kind).startsWith('staff-') && !current.config.staffNotifications)) {
115
120
  await repo.email.finish(j,{state:'cancelled',error:'Optional notification disabled.'}); continue;
@@ -11,7 +11,7 @@ export interface AppPrivacyAnswers { rows: StoreRow[]; tracking: string; notes:
11
11
  export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacyAnswers {
12
12
  const rows: StoreRow[] = [];
13
13
  // apps can RSVP (or join a full date's waitlist) wherever the calendar is on; room bookings need bookings too
14
- const contact = [...(f.bookings ? ['bookings'] : []), ...(f.calendar ? ['RSVPs (including waitlist requests)'] : []), ...(f.appForms ? ['forms'] : [])];
14
+ const contact = [...(f.bookings ? ['bookings'] : []), ...(f.calendar ? ['RSVPs (including waitlist requests)'] : []), ...(f.appForms ? ['forms'] : []), ...(f.requests.enabled ? ['event requests'] : [])];
15
15
  const sends = contact.length > 1 ? `${contact.slice(0, -1).join(', ')} or ${contact.at(-1)}` : contact[0];
16
16
  if (contact.length) {
17
17
  rows.push({
@@ -20,7 +20,7 @@ export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacy
20
20
  google: 'Personal info → Name, Email address, Phone number. Collected, not shared. Purpose: App functionality. Optional (people choose to send it).',
21
21
  });
22
22
  rows.push({
23
- data: 'Answers typed into a form or booking question', when: `When someone sends ${sends} from the app`,
23
+ data: f.requests.enabled ? 'Answers typed into a form or booking question, and the message of an event request' : 'Answers typed into a form or booking question', when: `When someone sends ${sends} from the app`,
24
24
  apple: 'User Content → Other User Content. Purpose: App Functionality. Linked to the user. Not used for tracking.',
25
25
  google: 'App activity → Other user-generated content. Collected, not shared. Purpose: App functionality. Optional.',
26
26
  });
@@ -4,6 +4,7 @@ import type { Settings } from '../schema';
4
4
  import { CALENDAR, bookingsEnabled } from '../calendar/config';
5
5
  import { PUSH } from '../push/config';
6
6
  import { ANNOUNCEMENTS } from '../announcements/config';
7
+ import { REQUESTS } from '../calendar/request-config';
7
8
  import { AUTH } from '../auth/config';
8
9
  import { EDITOR } from '../editor/config';
9
10
  import { SITE_ROLES } from '../roles';
@@ -44,6 +45,8 @@ export interface LegalFacts {
44
45
  googleCalendar: boolean;
45
46
  /** acms({ announcements }): urgent ones reach every device; read and closed state stays in the app */
46
47
  announcements: boolean;
48
+ /** acms({ requests }): event requests without an account, kept keepDays after the answer */
49
+ requests: { enabled: boolean; keepDays: number };
47
50
  analytics: { umami: boolean; replay: boolean; meta: boolean; tiktok: boolean };
48
51
  ai: boolean;
49
52
  offsiteBackups: boolean;
@@ -86,6 +89,7 @@ export async function legalFacts(repo: ContentRepository, settings: Settings, lo
86
89
  live: settings.live.mode !== 'off',
87
90
  googleCalendar: CALENDAR.enabled && await repo.gcal.account().then((a) => !!a && a.status !== 'setup').catch(() => false),
88
91
  announcements: ANNOUNCEMENTS.enabled,
92
+ requests: { enabled: REQUESTS.enabled, keepDays: settings.calendar.requests.keepDays },
89
93
  analytics: {
90
94
  umami: a.umami.enabled && !!a.umami.websiteId,
91
95
  replay: a.umami.enabled && !!a.umami.websiteId && a.umami.replay.enabled,
@@ -49,6 +49,9 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
49
49
  s.push({ id: 'forms', title: 'Forms', text: [
50
50
  `When you send a form, we store your answers as you entered them (for example your name, email address or phone number, if the form asks for them) and when you sent it. Staff with access to form responses can read and export them. We don’t store your IP address with a response.${f.email ? ' Responses, or a notice that one arrived, may be emailed to the staff who handle that form.' : ''}`,
51
51
  ] });
52
+ if (f.requests.enabled) s.push({ id: 'requests', title: 'Event requests', text: [
53
+ `When you ask us for an event, or a change to one, on this site${app ? ' or in our app' : ''}, we store your name, email address, any phone number, the language you used, the details you give (what, when, which rooms, how many people) and your message. We use them only to answer your request: staff who manage the calendar read it, and we email you that it arrived and our answer. Your contact details never appear on a published event. We delete a request ${days(f.requests.keepDays)} after we answer it, or ${days(f.requests.keepDays)} after it arrives if we never do; site backups may keep a copy for a while longer.`,
54
+ ] });
52
55
  if (f.guestSignIn) s.push({ id: 'guest', title: 'Signing in to see your bookings', text: [
53
56
  'To see your bookings in our app, you can sign in with your email address. We email you a six-digit code that expires after 10 minutes. We keep your email address and that sign-in on your device until you sign out or don’t use it for 90 days.',
54
57
  ] });
@@ -50,6 +50,9 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
50
50
  s.push({ id: 'forms', title: 'Formularios', text: [
51
51
  `Cuando envía un formulario, guardamos sus respuestas tal como las escribió (por ejemplo su nombre, correo o teléfono, si el formulario los pide) y la fecha en que lo envió. El personal con acceso a las respuestas puede leerlas y exportarlas. No guardamos su dirección IP con la respuesta.${f.email ? ' Sus respuestas, o un aviso de que llegaron, pueden enviarse por correo al personal que atiende ese formulario.' : ''}`,
52
52
  ] });
53
+ if (f.requests.enabled) s.push({ id: 'requests', title: 'Solicitudes de eventos', text: [
54
+ `Cuando nos pide un evento, o un cambio a uno, en este sitio${app ? ' o en nuestra app' : ''}, guardamos su nombre, su correo electrónico, el teléfono si lo da, el idioma que usó, los detalles que nos da (qué, cuándo, qué salones, cuántas personas) y su mensaje. Los usamos solo para responder a su solicitud: el personal que administra el calendario la lee, y le escribimos por correo que llegó y nuestra respuesta. Sus datos de contacto nunca aparecen en un evento publicado. Borramos la solicitud ${days(f.requests.keepDays)} después de responderla, o ${days(f.requests.keepDays)} después de recibirla si no la respondemos; las copias de seguridad del sitio pueden conservar una copia un tiempo más.`,
55
+ ] });
53
56
  if (f.guestSignIn) s.push({ id: 'guest', title: 'Inicio de sesión para ver sus reservaciones', text: [
54
57
  'Para ver sus reservaciones en nuestra app, puede iniciar sesión con su correo electrónico. Le enviamos un código de seis dígitos que vence a los 10 minutos. Guardamos su correo y esa sesión en su dispositivo hasta que la cierre o pasen 90 días sin usarla.',
55
58
  ] });