@acms-subir/core 0.3.47 → 0.3.49

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 (48) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/cli/migrations.generated.mjs +42 -23
  3. package/docs/calendar.md +80 -0
  4. package/docs/server.md +28 -3
  5. package/docs/theme-authoring.md +1 -1
  6. package/integration.mjs +37 -0
  7. package/package.json +1 -1
  8. package/src/actions/index.ts +76 -11
  9. package/src/components/EventPage.astro +18 -12
  10. package/src/components/calendar/CalendarBoard.tsx +43 -19
  11. package/src/components/editor/event/EventSheet.tsx +2 -0
  12. package/src/components/editor/event/SingleDates.tsx +123 -0
  13. package/src/lib/booking/holds.ts +7 -7
  14. package/src/lib/calendar/api.ts +11 -1
  15. package/src/lib/calendar/board.ts +7 -3
  16. package/src/lib/calendar/cards.ts +5 -5
  17. package/src/lib/calendar/change-migration.ts +40 -15
  18. package/src/lib/calendar/data.ts +9 -3
  19. package/src/lib/calendar/event-words.ts +9 -0
  20. package/src/lib/calendar/events.ts +94 -9
  21. package/src/lib/calendar/ics-write.ts +25 -2
  22. package/src/lib/calendar/moves.ts +291 -0
  23. package/src/lib/calendar/render.ts +10 -6
  24. package/src/lib/calendar/schedule.ts +73 -61
  25. package/src/lib/email/booking-words.ts +7 -1
  26. package/src/lib/email/service.ts +6 -1
  27. package/src/lib/email/templates.ts +6 -2
  28. package/src/lib/mobile/calendar.ts +5 -2
  29. package/src/lib/mobile/schemas.ts +7 -3
  30. package/src/lib/origin-check.ts +54 -0
  31. package/src/lib/push/service.ts +44 -10
  32. package/src/lib/push/types.ts +2 -0
  33. package/src/lib/repo/migrations.ts +5 -2
  34. package/src/lib/repo/sql.ts +28 -10
  35. package/src/lib/repo/types.ts +6 -3
  36. package/src/lib/schema.ts +21 -0
  37. package/src/lib/sections.ts +5 -2
  38. package/src/lib/sync/catalog.ts +1 -0
  39. package/src/middleware.ts +5 -0
  40. package/src/pages/api/calendar/events/index.ts +6 -0
  41. package/src/pages/members/_Layout.astro +1 -0
  42. package/src/pages/members/events/index.astro +1 -1
  43. package/src/server/index.ts +33 -0
  44. package/src/styles/editor.css +19 -0
  45. package/src/styles/schedule.css +2 -0
  46. package/src/styles/site.css +6 -0
  47. package/src/theme-sdk/calendar.ts +3 -2
  48. package/src/virtual.d.ts +2 -0
@@ -12,11 +12,15 @@ export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|
12
12
  return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
13
13
  html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
14
14
  }
15
- /** members' event requests (lib/calendar/requests.ts): to staff when one arrives, to the member when it's answered */
16
- export function eventRequestMail(kind: 'staff-new' | 'staff-changes' | 'approved' | 'declined', to: string, site: string, event: { title: string; when: string; by: string; note: string }, reply: string, url: string): Mail {
15
+ /**
16
+ * members' event requests (lib/calendar/requests.ts): to staff when one arrives, to the member when it's answered;
17
+ * staff-moved: a host moved one date (lib/calendar/moves.ts), `when` = "old → new"
18
+ */
19
+ export function eventRequestMail(kind: 'staff-new' | 'staff-changes' | 'staff-moved' | 'approved' | 'declined', to: string, site: string, event: { title: string; when: string; by: string; note: string }, reply: string, url: string): Mail {
17
20
  const copy = {
18
21
  'staff-new': [`New event request: ${event.title}`, `${event.by || 'A member'} asked for “${event.title}” (${event.when}). It stays a draft until someone approves it.`, 'Review it'],
19
22
  'staff-changes': [`Changes to review: ${event.title}`, `${event.by || 'A host'} suggested changes to “${event.title}” (${event.when}). The live event stays as it is until someone approves them.`, 'Review the changes'],
23
+ 'staff-moved': [`Date moved: ${event.title}`, `${event.by || 'A host'} moved one date of “${event.title}”: ${event.when}. People who signed up for it were emailed the new time.`, 'Open the event'],
20
24
  approved: [`Approved: ${event.title}`, `Your event “${event.title}” (${event.when}) was approved and is now on the calendar.`, 'See your events'],
21
25
  declined: [`Not approved: ${event.title}`, `Your request for “${event.title}” (${event.when}) wasn’t approved.`, 'See your events'],
22
26
  }[kind];
@@ -106,9 +106,12 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
106
106
  }
107
107
  /** every room by id, for naming the public ones (Resource.showOnEvents) */
108
108
  export const roomMap = async (repo: ContentRepository) => new Map((await repo.listResources()).map((r) => [r.id, r]));
109
- // one date: its time, whether it's cancelled, and the public rooms it holds
109
+ // one date: its time, whether it's cancelled, where it moved from, and the public rooms it holds
110
110
  export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>) {
111
- return { id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, cancelled: o.cancelled,
111
+ const iso = (n: number) => new Date(n).toISOString();
112
+ return { id: `${o.event.id}:${o.start}`, start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled,
113
+ moved: o.moved ? { from: { start: iso(o.moved.from.start), end: iso(o.moved.from.end) }, note: o.moved.note,
114
+ location: o.moved.location ? { name: o.moved.location.name, address: o.moved.location.address, url: o.moved.location.url } : null } : null,
112
115
  rooms: roomsToJson(o.roomIds, resources) };
113
116
  }
114
117
  // e is already localized; publicCalendars limits which calendars are named
@@ -29,24 +29,28 @@ export const Page = ContentSummary.extend({
29
29
  });
30
30
  // a room an event (or one date) holds, named only when set to show on public events; color null = none
31
31
  export const EventRoom = z.object({ slug: str, name: str, color: str.nullable() });
32
+ // one date moved here from `from` (null when it wasn't); location: its own place, null = the event's
33
+ export const OccurrenceMoved = z.object({ from: z.object({ start: date, end: date }), note: str, location: z.object({ name: str, address: str, url: str }).nullable() }).nullable();
32
34
  export const Event = CalEvent.pick({
33
35
  id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
34
36
  allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
35
37
  }).extend({ rsvp: CalEvent.shape.rsvp.removeDefault().omit({ notifyStaff: true }).default({}).optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
36
38
  calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
37
39
  rooms: z.array(EventRoom),
40
+ // single dates at another time, by original occurrence key (wall-clock, like start/end)
41
+ movedDates: z.record(z.object({ start: str, end: str, note: str })),
38
42
  // push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
39
43
  notify: z.object({ changes: strings }),
40
44
  });
41
45
  export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
42
- occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom), event: Event })),
46
+ occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom), event: Event })),
43
47
  });
44
48
  const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
45
49
  const ContentHit = ContentSummary.extend({ snippet: Snippet });
46
- export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom) }).nullable(), event: Event });
50
+ export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom) }).nullable(), event: Event });
47
51
  export const SearchResults = z.object({ locale, query: str,
48
52
  posts: z.array(ContentHit), pages: z.array(ContentHit),
49
- events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom) }).nullable(), event: Event })),
53
+ events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom) }).nullable(), event: Event })),
50
54
  more: z.object({ posts: bool, pages: bool, events: bool }),
51
55
  });
52
56
  const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
@@ -0,0 +1,54 @@
1
+ import { userConfig } from 'virtual:acms-config';
2
+
3
+ // Astro's CSRF origin check (security.checkOrigin), taken over by core.
4
+ // Astro's own copy runs before any middleware and no route can opt out of it,
5
+ // so requests that never come from a browser on another site were refused
6
+ // too: Amazon SNS posts SES bounces as text/plain with no Origin header, and
7
+ // the apps send none either. integration.mjs turns Astro's copy off and the
8
+ // middleware runs this one first instead, in dev as well as on built sites.
9
+ //
10
+ // The rule is Astro 5's for anything a page on another site can send: a POST
11
+ // whose Origin isn't this site's (or that has none) is refused when its body
12
+ // is form-like (form-urlencoded, multipart, text/plain) or it has no
13
+ // Content-Type. JSON and other types need a CORS preflight and pass. What
14
+ // passes besides: the routes below, and requests a browser only sends to
15
+ // another site after a preflight (preflighted()).
16
+
17
+ const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
18
+ const FORM_TYPES = ['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'];
19
+
20
+ // Routes whose senders are servers that send no Origin and prove who they are
21
+ // with a signature the route checks itself (lib/email/webhooks.ts), keyed by
22
+ // route pattern as Astro matched it, so a lookalike path never qualifies. A
23
+ // site adds its own with acms({ signedRoutes }).
24
+ export const SIGNED_ROUTES = new Set([
25
+ '/api/email/webhooks/[provider]',
26
+ ...(userConfig.originCheck?.signedRoutes ?? []),
27
+ ]);
28
+
29
+ // The editor apps' OAuth endpoints (RFC 6749): form posts that carry their
30
+ // own code, refresh token or token to revoke, and never use cookies.
31
+ export const TOKEN_ROUTES = new Set(['/api/v1/auth/token', '/api/v1/auth/revoke']);
32
+
33
+ // A request no browser sends to another site without asking it first (a CORS
34
+ // preflight; ACMS never allows credentials in one, so no cookie rides along):
35
+ // any method but POST, or one carrying an app's own credential, a bearer
36
+ // token or a booking token. Basic auth doesn't count: a browser adds that to
37
+ // a form post by itself.
38
+ export function preflighted(request: Request): boolean {
39
+ return request.method !== 'POST' || /^bearer\s/i.test(request.headers.get('authorization') ?? '') || request.headers.has('booking-token');
40
+ }
41
+
42
+ // a site that set security.checkOrigin: false itself keeps the check off
43
+ export const ORIGIN_CHECK = { enabled: userConfig.originCheck?.enabled !== false };
44
+
45
+ type CheckedContext = { request: Request; url: URL; routePattern: string; isPrerendered: boolean };
46
+
47
+ /** Astro's 403 for a cross-site form submission, or null when the request may go on. */
48
+ export function crossSiteRefusal({ request, url, routePattern, isPrerendered }: CheckedContext): Response | null {
49
+ if (!ORIGIN_CHECK.enabled || isPrerendered || SAFE_METHODS.includes(request.method)) return null;
50
+ if (request.headers.get('origin') === url.origin || SIGNED_ROUTES.has(routePattern) || TOKEN_ROUTES.has(routePattern) || preflighted(request)) return null;
51
+ const type = request.headers.get('content-type');
52
+ if (type !== null && !FORM_TYPES.some((form) => type.toLowerCase().includes(form))) return null;
53
+ return new Response(`Cross-site ${request.method} form submissions are forbidden`, { status: 403 });
54
+ }
@@ -96,23 +96,30 @@ interface Words {
96
96
  // event changes: dates come formatted, `more` ends a list of cancelled dates
97
97
  moved: (to: string, was: string) => string; movedTo: (to: string, place: string) => string; place: (p: string) => string;
98
98
  cancelled: (d: string) => string; cancelledFrom: (d: string) => string; cancelledOn: (d: string) => string; more: (n: number) => string;
99
+ // one date of a series moved (CalEvent.movedDates), or several: their new days
100
+ dateMoved: (to: string, was: string) => string; datesMoved: (d: string) => string;
99
101
  }
100
102
  const STRINGS: Record<string, Words> = {
101
103
  en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live',
102
104
  moved: (to, was) => `Now ${to} (was ${was})`, movedTo: (to, p) => `Now ${to} · ${p}`, place: (p) => `New place: ${p}`,
103
- cancelled: (d) => `Cancelled: ${d}`, cancelledFrom: (d) => `Cancelled: every date from ${d}`, cancelledOn: (d) => `Cancelled on ${d}`, more: (n) => `${n} more` },
105
+ cancelled: (d) => `Cancelled: ${d}`, cancelledFrom: (d) => `Cancelled: every date from ${d}`, cancelledOn: (d) => `Cancelled on ${d}`, more: (n) => `${n} more`,
106
+ dateMoved: (to, was) => `Moved to ${to} (was ${was})`, datesMoved: (d) => `New times on ${d}` },
104
107
  es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo',
105
108
  moved: (to, was) => `Ahora ${to} (antes ${was})`, movedTo: (to, p) => `Ahora ${to} · ${p}`, place: (p) => `Nuevo lugar: ${p}`,
106
- cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas las fechas desde el ${d}`, cancelledOn: (d) => `Cancelado el ${d}`, more: (n) => `${n} más` },
109
+ cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas las fechas desde el ${d}`, cancelledOn: (d) => `Cancelado el ${d}`, more: (n) => `${n} más`,
110
+ dateMoved: (to, was) => `Se cambia al ${to} (antes ${was})`, datesMoved: (d) => `Nuevo horario el ${d}` },
107
111
  fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct',
108
112
  moved: (to, was) => `Désormais ${to} (au lieu de ${was})`, movedTo: (to, p) => `Désormais ${to} · ${p}`, place: (p) => `Nouveau lieu : ${p}`,
109
- cancelled: (d) => `Annulé : ${d}`, cancelledFrom: (d) => `Annulé : toutes les dates à partir du ${d}`, cancelledOn: (d) => `Annulé le ${d}`, more: (n) => `${n} autres` },
113
+ cancelled: (d) => `Annulé : ${d}`, cancelledFrom: (d) => `Annulé : toutes les dates à partir du ${d}`, cancelledOn: (d) => `Annulé le ${d}`, more: (n) => `${n} autres`,
114
+ dateMoved: (to, was) => `Déplacé au ${to} (au lieu de ${was})`, datesMoved: (d) => `Nouveaux horaires le ${d}` },
110
115
  de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live',
111
116
  moved: (to, was) => `Jetzt ${to} (statt ${was})`, movedTo: (to, p) => `Jetzt ${to} · ${p}`, place: (p) => `Neuer Ort: ${p}`,
112
- cancelled: (d) => `Abgesagt: ${d}`, cancelledFrom: (d) => `Abgesagt: alle Termine ab ${d}`, cancelledOn: (d) => `Abgesagt am ${d}`, more: (n) => `${n} weitere` },
117
+ cancelled: (d) => `Abgesagt: ${d}`, cancelledFrom: (d) => `Abgesagt: alle Termine ab ${d}`, cancelledOn: (d) => `Abgesagt am ${d}`, more: (n) => `${n} weitere`,
118
+ dateMoved: (to, was) => `Verschoben auf ${to} (statt ${was})`, datesMoved: (d) => `Neue Zeiten am ${d}` },
113
119
  pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo',
114
120
  moved: (to, was) => `Agora ${to} (antes ${was})`, movedTo: (to, p) => `Agora ${to} · ${p}`, place: (p) => `Novo local: ${p}`,
115
- cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas as datas a partir de ${d}`, cancelledOn: (d) => `Cancelado em ${d}`, more: (n) => `mais ${n}` },
121
+ cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas as datas a partir de ${d}`, cancelledOn: (d) => `Cancelado em ${d}`, more: (n) => `mais ${n}`,
122
+ dateMoved: (to, was) => `Mudou para ${to} (antes ${was})`, datesMoved: (d) => `Novos horários em ${d}` },
116
123
  };
117
124
  export const pushStrings = (locale: string) => STRINGS[locale.split('-')[0]] ?? STRINGS.en;
118
125
 
@@ -291,7 +298,8 @@ export const CHANGE_SOON = 3 * HOUR;
291
298
  /** The public fields a change notice compares (staged edits live in `draft` and don't count until applied). */
292
299
  export function eventSnapshot(e: CalEvent): EventSnapshot {
293
300
  return { start: e.start, end: e.end, allDay: e.allDay, timezone: e.timezone, rrule: e.rrule, exdates: [...e.exdates].sort(),
294
- cancelled: e.cancelled, cancelledDates: [...e.cancelledDates].sort(), location: { name: e.location.name.trim(), address: e.location.address.trim() } };
301
+ cancelled: e.cancelled, cancelledDates: [...e.cancelledDates].sort(), location: { name: e.location.name.trim(), address: e.location.address.trim() },
302
+ movedDates: Object.fromEntries(Object.keys(e.movedDates ?? {}).sort().map((k) => [k, { start: e.movedDates[k].start, end: e.movedDates[k].end }])) };
295
303
  }
296
304
  function readSnapshot(raw: string | null): EventSnapshot | null {
297
305
  try { const r = EventSnapshot.safeParse(JSON.parse(raw ?? '')); return r.success ? r.data : null; } catch { return null; }
@@ -307,6 +315,8 @@ export interface EventChange {
307
315
  cancelled?: Slot;
308
316
  /** single dates of a repeating event newly cancelled, soonest first */
309
317
  cancelledDates?: Slot[];
318
+ /** single dates of a repeating event at a new time (moved, moved again or moved back), by their new time */
319
+ movedDates?: { from: Slot; to: Slot }[];
310
320
  /** when the soonest date it touches starts (a move: the old or the new time, whichever is sooner) */
311
321
  at: number;
312
322
  }
@@ -331,6 +341,7 @@ export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Sett
331
341
  const { upcoming } = slots(e, settings, now);
332
342
  if (!upcoming) return null;
333
343
  if (e.cancelled) return { cancelled: upcoming, at: upcoming.start };
344
+ // the series as people knew it, with today's moved dates (those are compared one by one below)
334
345
  const then: CalEvent = { ...e, start: was.start, end: was.end, allDay: was.allDay, timezone: was.timezone, rrule: was.rrule, exdates: was.exdates, cancelledDates: was.cancelledDates };
335
346
  // a date taken out of the series (dragged to its own event) or put back isn't a move
336
347
  const exdates = [...new Set([...was.exdates, ...e.exdates])];
@@ -353,6 +364,16 @@ export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Sett
353
364
  out.cancelledDates = dates.map((o) => ({ start: o.start, allDay: o.allDay }));
354
365
  starts.push(dates[0].start);
355
366
  }
367
+ // single dates at a new time (a cancelled one is reported above as cancelled)
368
+ const knew: CalEvent = { ...then, movedDates: Object.fromEntries(Object.entries(was.movedDates).map(([k, m]) => [k, { ...m, note: '', by: '', at: 0 }])) };
369
+ const moves = [...new Set([...Object.keys(was.movedDates), ...Object.keys(e.movedDates ?? {})])].filter((k) => !e.cancelledDates.includes(k))
370
+ .map((k) => ({ a: findOccurrence(knew, k, settings), b: findOccurrence(e, k, settings) }))
371
+ .filter((m): m is { a: Occurrence; b: Occurrence } => m.a !== null && m.b !== null && (m.a.start !== m.b.start || m.a.end !== m.b.end) && m.b.start > now)
372
+ .sort((x, y) => x.b.start - y.b.start);
373
+ if (moves.length) {
374
+ out.movedDates = moves.map(({ a, b }) => ({ from: { start: a.start, allDay: a.allDay }, to: { start: b.start, allDay: b.allDay } }));
375
+ starts.push(Math.min(...moves.map(({ a, b }) => (a.start > now ? Math.min(a.start, b.start) : b.start))));
376
+ }
356
377
  return starts.length ? { ...out, at: Math.min(...starts) } : null;
357
378
  }
358
379
 
@@ -396,11 +417,13 @@ export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Se
396
417
  // all-day dates start at midnight in the site's zone (how they were expanded)
397
418
  const zone = (x: Slot) => (x.allDay ? siteTz : tz);
398
419
  const dates = change.cancelledDates ?? [];
399
- const kind = change.cancelled ? 'cancelled' : change.moved ? 'moved' : change.place ? 'place' : 'dateCancelled';
400
- // one cancelled date opens that date (?on= is its day in the site's zone, like calendar links)
420
+ const movedDates = change.movedDates ?? [];
421
+ const kind = change.cancelled ? 'cancelled' : change.moved ? 'moved' : change.place ? 'place' : movedDates.length ? 'dateMoved' : 'dateCancelled';
422
+ // one cancelled or moved date opens that date (?on= is its day in the site's zone, like calendar links)
401
423
  let on = '';
402
- if (kind === 'dateCancelled' && dates.length === 1) {
403
- const p = tzParts(dates[0].start, siteTz);
424
+ const one = kind === 'dateCancelled' && dates.length === 1 ? dates[0] : kind === 'dateMoved' && movedDates.length === 1 && !dates.length ? movedDates[0].to : null;
425
+ if (one) {
426
+ const p = tzParts(one.start, siteTz);
404
427
  on = `?on=${p.y}-${String(p.m).padStart(2, '0')}-${String(p.d).padStart(2, '0')}`;
405
428
  }
406
429
  const words = (ev: CalEvent, locale: string): Composed => {
@@ -418,6 +441,17 @@ export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Se
418
441
  const sameDay = !from.allDay && !to.allDay && day(from) === day(to);
419
442
  parts.push(place ? s.movedTo(at(to), place) : s.moved(at(to), sameDay ? when(from.start, false, zone(from), locale, 'time') : at(from)));
420
443
  } else if (place) parts.push(s.place(place));
444
+ if (movedDates.length === 1) {
445
+ const { from, to } = movedDates[0];
446
+ const sameDay = !from.allDay && !to.allDay && day(from) === day(to);
447
+ parts.push(s.dateMoved(at(to), sameDay ? when(from.start, false, zone(from), locale, 'time') : at(from)));
448
+ } else if (movedDates.length) {
449
+ const list = movedDates.slice(0, 3).map(({ to }) => when(to.start, true, zone(to), locale, 'monthDay'));
450
+ if (movedDates.length > 3) list.push(s.more(movedDates.length - 3));
451
+ let joined: string;
452
+ try { joined = new Intl.ListFormat(locale, { type: 'conjunction' }).format(list); } catch { joined = list.join(', '); }
453
+ parts.push(s.datesMoved(joined));
454
+ }
421
455
  if (dates.length) {
422
456
  const list = dates.length === 1 ? [day(dates[0])] : dates.slice(0, 3).map((x) => when(x.start, true, zone(x), locale, 'monthDay'));
423
457
  if (dates.length > 3) list.push(s.more(dates.length - 3));
@@ -38,6 +38,8 @@ export type PushSettings = z.infer<typeof PushSettings>;
38
38
  export const EventSnapshot = z.object({
39
39
  start: z.string(), end: z.string(), allDay: z.boolean(), timezone: z.string(), rrule: z.string(), exdates: z.array(z.string()),
40
40
  cancelled: z.boolean(), cancelledDates: z.array(z.string()), location: z.object({ name: z.string(), address: z.string() }),
41
+ // single dates at another time (key → new wall times); notices queued before moved dates have none
42
+ movedDates: z.record(z.object({ start: z.string(), end: z.string() })).default({}),
41
43
  });
42
44
  export type EventSnapshot = z.infer<typeof EventSnapshot>;
43
45
 
@@ -5,7 +5,7 @@ import { WEBHOOK_MIGRATION } from '../webhook-sql';
5
5
  import { PUSH_MIGRATION } from '../push/migration';
6
6
  import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
7
7
  import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
8
- import { EVENT_CHANGES_MIGRATION } from '../calendar/change-migration';
8
+ import { EVENT_CHANGES_MIGRATION, MOVED_DATES_MIGRATION } from '../calendar/change-migration';
9
9
  import { PLACES_MIGRATION } from '../maps/migration';
10
10
  import { ROLES_MIGRATION } from '../permissions/migration';
11
11
  import { SYNC_MIGRATION } from '../sync/migration';
@@ -724,13 +724,16 @@ export const MIGRATIONS: Migration[] = [
724
724
  // every change to a published event, from any write path, queued for its
725
725
  // side effects (webhooks, "event changed" pushes, slug redirects)
726
726
  EVENT_CHANGES_MIGRATION,
727
+ // single dates of a series moved (CalEvent.movedDates): a column on the
728
+ // row, so the triggers on events (content version, event_changes) see a move
729
+ MOVED_DATES_MIGRATION(contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'])[2]),
727
730
  ];
728
731
 
729
732
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
730
733
  export const CURRENT_CONTENT_VERSION_COLUMNS = {
731
734
  ...CONTENT_VERSION_COLUMNS,
732
735
  settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
733
- events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
736
+ events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'],
734
737
  resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'],
735
738
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
736
739
  };
@@ -13,7 +13,7 @@ import { LiveConfig } from '../live/types';
13
13
  import { ApiError } from '../api';
14
14
  import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
15
15
  import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
16
- import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
16
+ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, MovedDate, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
17
17
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
18
18
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
19
19
  import { cleanRrule } from '../calendar/rrule';
@@ -148,6 +148,15 @@ function parseRequest(raw: unknown): EventRequest | null {
148
148
  if (raw === null || raw === undefined || raw === '') return null;
149
149
  try { const r = EventRequest.safeParse(JSON.parse(String(raw))); return r.success ? r.data : null; } catch { return null; }
150
150
  }
151
+ // a corrupt moved-dates map, or one bad entry, reads as "not moved" rather than breaking the event
152
+ function parseMovedDates(raw: unknown): CalEvent['movedDates'] {
153
+ let value: unknown;
154
+ try { value = JSON.parse(String(raw ?? '{}')); } catch { return {}; }
155
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
156
+ const out: CalEvent['movedDates'] = {};
157
+ for (const [key, entry] of Object.entries(value)) { const m = MovedDate.safeParse(entry); if (m.success) out[key] = m.data; }
158
+ return out;
159
+ }
151
160
  function rowToEvent(r: Row): CalEvent {
152
161
  return CalEvent.parse({
153
162
  id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
@@ -155,7 +164,7 @@ function rowToEvent(r: Row): CalEvent {
155
164
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
156
165
  featured: Boolean(r.featured), tags: json(r.tags, []), template: r.template ?? '',
157
166
  start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
158
- rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []),
167
+ rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []), movedDates: parseMovedDates(r.moved_dates),
159
168
  location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
160
169
  fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
161
170
  hostIds: json(r.host_ids, []), request: parseRequest(r.request),
@@ -848,8 +857,8 @@ export class SqlRepository implements ContentRepository {
848
857
  await this.driver.run(
849
858
  `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
850
859
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
851
- published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations)
852
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
860
+ published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations, moved_dates)
861
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
853
862
  [
854
863
  id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
855
864
  data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
@@ -859,6 +868,7 @@ export class SqlRepository implements ContentRepository {
859
868
  data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
860
869
  JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
861
870
  JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null, JSON.stringify(data.translations ?? {}),
871
+ JSON.stringify(data.movedDates ?? {}),
862
872
  ]
863
873
  );
864
874
  await this.syncEventIndex(id);
@@ -883,7 +893,7 @@ export class SqlRepository implements ContentRepository {
883
893
  if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
884
894
  }
885
895
  const jsonCols: Record<string, string> = {
886
- tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
896
+ tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', movedDates: 'moved_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
887
897
  };
888
898
  for (const [k, col] of Object.entries(jsonCols)) {
889
899
  const v = (data as Record<string, unknown>)[k];
@@ -955,6 +965,14 @@ export class SqlRepository implements ContentRepository {
955
965
  await this.ready;
956
966
  await this.driver.run('UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', [JSON.stringify(dates), Date.now(), id]);
957
967
  }
968
+ async setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts: { quiet?: boolean } = {}) {
969
+ await this.ready;
970
+ const write = cancelledDates
971
+ ? { sql: 'UPDATE events SET moved_dates = ?, cancelled_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), JSON.stringify(cancelledDates), Date.now(), id] }
972
+ : { sql: 'UPDATE events SET moved_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), Date.now(), id] };
973
+ // quiet: the change it records sends its webhook but no member push (the flag shares this transaction)
974
+ await this.driver.batch(opts.quiet ? [{ sql: 'UPDATE push_control SET quiet=1 WHERE id=1' }, write, { sql: 'UPDATE push_control SET quiet=0 WHERE id=1' }] : [write]);
975
+ }
958
976
  async cancelOccurrenceRsvps(id: string, start: number) {
959
977
  await this.ready;
960
978
  // the mutation id limits the "cancelled" emails to exactly these RSVPs (as deleteEvent does)
@@ -966,12 +984,12 @@ export class SqlRepository implements ContentRepository {
966
984
  ]);
967
985
  return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
968
986
  }
969
- async moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }) {
987
+ async moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }, message = '') {
970
988
  await this.ready;
971
989
  const target = to.eventId || id;
972
990
  // one mutation id marks every moved row; only the seated ones are emailed
973
991
  const mutation = crypto.randomUUID();
974
- const notifications = bookingJobs(target,'rescheduled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')")}));
992
+ const notifications = bookingJobs(target,'rescheduled',undefined,false,mutation,message).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')")}));
975
993
  await this.driver.batch([
976
994
  {sql:`UPDATE bookings SET event_id=?,start_at=?,end_at=?,hold_start=?,hold_end=?,notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=?
977
995
  WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ${RSVP_ACTIVE}`,args:[target,to.start,to.end,to.start,to.end,mutation,Date.now(),id,start]},
@@ -1906,15 +1924,15 @@ export class SqlRepository implements ContentRepository {
1906
1924
  ...(content.events ?? []).map((e) => ({
1907
1925
  sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
1908
1926
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
1909
- draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request)
1910
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1927
+ draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, moved_dates)
1928
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1911
1929
  args: [
1912
1930
  e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
1913
1931
  JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
1914
1932
  e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
1915
1933
  e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
1916
1934
  JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}), JSON.stringify(e.cancelledDates ?? []),
1917
- JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null,
1935
+ JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null, JSON.stringify(e.movedDates ?? {}),
1918
1936
  ],
1919
1937
  })),
1920
1938
  // announcements (a backup made before them leaves the current ones). Restored
@@ -48,7 +48,7 @@ export type EventUpdate = Partial<
48
48
  CalEvent,
49
49
  | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
50
50
  | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
51
- | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates'
51
+ | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates' | 'movedDates'
52
52
  | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
53
53
  | 'publishedAt' | 'scheduledAt'
54
54
  >
@@ -168,15 +168,18 @@ export interface ContentRepository {
168
168
  discardEventDraft(id: string): Promise<void>;
169
169
  /** replace an event's cancelled occurrence keys at once, keeping any staged draft */
170
170
  setEventCancelledDates(id: string, dates: string[]): Promise<void>;
171
+ /** replace an event's moved dates (and cancelled dates, when given) at once, keeping any staged draft; quiet: no member push for it */
172
+ setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts?: { quiet?: boolean }): Promise<void>;
171
173
  /** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
172
174
  cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
173
175
  /**
174
176
  * Moves the RSVPs for one date of an event (those starting at `start`) to a
175
177
  * new time, and to another event when `to.eventId` is set. Pending and
176
- * confirmed ones get the "new time" email; places in line move quietly.
178
+ * confirmed ones get the "new time" email (with `message`, when given);
179
+ * places in line move quietly.
177
180
  * Returns how many moved.
178
181
  */
179
- moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }): Promise<number>;
182
+ moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }, message?: string): Promise<number>;
180
183
  deleteEvent(id: string): Promise<void>;
181
184
  // saved-place sync (lib/maps/places.ts): rewrite just the location of the
182
185
  // live row and/or the staged draft, leaving every other field — and the
package/src/lib/schema.ts CHANGED
@@ -357,6 +357,24 @@ export const RoomHold = z.object({
357
357
  });
358
358
  export type RoomHold = z.infer<typeof RoomHold>;
359
359
 
360
+ // One date of a repeating event at another time (and maybe in other rooms or
361
+ // another place): CalEvent.movedDates, keyed by the date's ORIGINAL occurrence
362
+ // key. start/end are wall-clock in the event's zone like the event's own
363
+ // (dates for all-day events, end inclusive). Unset roomIds/location = the
364
+ // event's. note is public ("Moved for the youth retreat"); by is who moved it
365
+ // (a user id, or 'google-sync'), at when. lib/calendar/moves.ts writes these.
366
+ export const MovedDate = z.object({
367
+ start: z.string(),
368
+ end: z.string(),
369
+ roomIds: z.array(z.string()).optional(),
370
+ location: EventLocation.optional(),
371
+ note: z.string().max(500).default(''),
372
+ by: z.string().default(''),
373
+ at: z.number().default(0),
374
+ });
375
+ export type MovedDate = z.infer<typeof MovedDate>;
376
+ export const MovedDates = z.record(z.string(), MovedDate).default({});
377
+
360
378
  // A member's event request (lib/calendar/requests.ts): a new event waiting
361
379
  // for approval (kind 'new', the event stays a draft), or changes to a live
362
380
  // event staged in its `draft` (kind 'changes'). Staff approve or decline;
@@ -407,6 +425,9 @@ export const CalEvent = z.object({
407
425
  // cancelled: shown struck through, but they don't hold rooms. Keys that no
408
426
  // longer match an occurrence (the series moved) are ignored.
409
427
  cancelledDates: z.array(z.string()).default([]),
428
+ // single dates at another time (MovedDate above); a date is moved or
429
+ // cancelled, never both. Keys that no longer match an occurrence are ignored.
430
+ movedDates: MovedDates,
410
431
  location: EventLocation.default({}),
411
432
  cta: EventCta.default({}),
412
433
  calendarIds: z.array(z.string()).default([]),
@@ -32,6 +32,8 @@ export interface LinkValue { label: string; href: string; external: boolean }
32
32
  export interface EventItem {
33
33
  title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
34
34
  start: string; end: string; allDay: boolean; cancelled: boolean;
35
+ /** this date was moved here: from "Thu, Oct 1 · 6:00 PM" (where it was), with staff's public note; null when not moved */
36
+ moved: { from: string; note: string } | null;
35
37
  /** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
36
38
  /** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
37
39
  cta: { label: string; href: string } | null;
@@ -141,11 +143,12 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
141
143
  return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag })).map((o): EventItem => {
142
144
  const e = lang ? localizeDoc(o.event, lang) : o.event;
143
145
  const f = formatOccurrence(o, tz, locale);
146
+ const was = o.moved ? formatOccurrence({ ...o, start: o.moved.from.start, end: o.moved.from.end }, tz, locale) : null;
144
147
  const ctaHref = safeUrl(e.cta.href);
145
148
  return {
146
149
  title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
147
- location: locationLine(e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
148
- allDay: o.allDay, cancelled: o.cancelled, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
150
+ location: locationLine(o.moved?.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
151
+ allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
149
152
  cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
150
153
  };
151
154
  });
@@ -130,6 +130,7 @@ const jsonFields = new Set([
130
130
  "roomHold",
131
131
  "exdates",
132
132
  "cancelledDates",
133
+ "movedDates",
133
134
  "amenities",
134
135
  "hours",
135
136
  "overrides",
package/src/middleware.ts CHANGED
@@ -15,6 +15,7 @@ import { processPushQueue, runPushJobs } from './lib/push/service';
15
15
  import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
16
16
  import { applyCachePolicy } from './lib/cache/policy';
17
17
  import { bindExecutionContext, syncCachePurge } from './lib/cache/purge';
18
+ import { crossSiteRefusal } from './lib/origin-check';
18
19
 
19
20
  // Legacy content-URL redirects, checked only on would-be 404s (below) so they
20
21
  // can never shadow real content. Tags moved from `${base}/tag/` to a
@@ -81,6 +82,10 @@ export const onRequest = defineMiddleware(async (context, next) =>
81
82
 
82
83
  async function handle(context: APIContext, next: MiddlewareNext): Promise<Response> {
83
84
  const { pathname } = context.url;
85
+ // cross-site form submissions, before anything else runs (Astro's own
86
+ // check, which signed webhooks and the apps couldn't pass: lib/origin-check.ts)
87
+ const refused = crossSiteRefusal(context);
88
+ if (refused) return refused;
84
89
  bindAuthEnvironment(context.request, context.locals);
85
90
  bindExecutionContext(context.request, context.locals);
86
91
 
@@ -100,6 +100,12 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
100
100
  end: o.allDay ? isoWithOffset(o.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.end, etz),
101
101
  allDay: o.allDay,
102
102
  cancelled: o.cancelled,
103
+ // the date's key (a moved date keeps its original one), and where it moved from
104
+ key: o.key,
105
+ moved: o.moved ? {
106
+ from: { start: o.allDay ? isoWithOffset(o.moved.from.start, etz).slice(0, 10) : isoWithOffset(o.moved.from.start, etz), end: o.allDay ? isoWithOffset(o.moved.from.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.moved.from.end, etz) },
107
+ note: o.moved.note, location: o.moved.location ?? null,
108
+ } : null,
103
109
  // that date's rooms
104
110
  rooms: roomsToJson(o.roomIds, resources, full),
105
111
  event: toJson(o.event.id),
@@ -62,6 +62,7 @@ const { title, user } = Astro.props;
62
62
  .pc-mem-dates li { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 42px; padding: 5px 0; border-top: 1px solid var(--line); font-variant-numeric: tabular-nums; }
63
63
  .pc-mem-dates li.is-cancelled > span { color: var(--muted); }
64
64
  .pc-mem-dates em { font-style: normal; font-size: 12.5px; font-weight: 600; color: var(--danger); margin-left: 8px; }
65
+ .pc-mem-dates em.is-moved { color: var(--accent); font-weight: 500; }
65
66
  .pc-mem-past > summary { cursor: pointer; font-weight: 600; font-size: 15px; }
66
67
  .pc-mem-past .pc-mem-list { margin-top: 10px; }
67
68
  /* the request form */
@@ -175,7 +175,7 @@ const pastShown = past.slice(0, 10);
175
175
  const off = e.cancelledDates.includes(o.key);
176
176
  return (
177
177
  <li class={o.cancelled ? 'is-cancelled' : undefined}>
178
- <span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}</span>
178
+ <span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}{o.moved && !o.cancelled && <em class="is-moved" title={o.moved.note || undefined}>Moved from {when({ ...o, start: o.moved.from.start, end: o.moved.from.end })}</em>}</span>
179
179
  {e.rrule && !e.cancelled && o.start > now && (
180
180
  <form method="post" onsubmit={off ? undefined : "return confirm('Cancel this date? It shows as cancelled straight away.')"}>
181
181
  <input type="hidden" name="op" value={off ? 'restore-date' : 'cancel-date'} />
@@ -37,6 +37,7 @@ export interface ServerUser {
37
37
  export type { CalEvent, Occurrence };
38
38
  export type { EventRequestInput } from '../lib/calendar/requests';
39
39
  export type { EventClash, BusySlot, Hold } from '../lib/booking/holds';
40
+ export type { MoveInput, MoveResult } from '../lib/calendar/moves';
40
41
 
41
42
  export interface ListEventsOptions {
42
43
  /** only published events (default: every event except archived ones) */
@@ -172,6 +173,38 @@ export async function restoreOccurrence(repo: Repo, eventId: string, occurrenceK
172
173
  return restore(internal(repo), eventId, occurrenceKey, { url: context.url, locals: context.locals });
173
174
  }
174
175
 
176
+ // ---- moved dates -------------------------------------------------------------
177
+ // One date of a repeating event at another time (and maybe other rooms or
178
+ // another place), keeping its key, its RSVPs (moved with it; each person gets
179
+ // the "new time" email with their cancel link) and its hosts. These check who
180
+ // is acting: staff ("Create and edit content") and GOOGLE_SYNC may move any
181
+ // date, and a room taken at the new time comes back in `clashes` as a
182
+ // warning; a host of the event may move its dates only while the rooms are
183
+ // free (409 otherwise), and staff are emailed about it.
184
+
185
+ /** The actor for a calendar sync acting with staff rights (a change already made elsewhere): clashes only warn. */
186
+ export const GOOGLE_SYNC = 'google-sync';
187
+
188
+ /**
189
+ * Move one date (by its occurrence key) to `input.start` (and `end`, else it
190
+ * keeps its length; wall-clock in the event's timezone, like `start`).
191
+ * `roomIds`/`location`/`note` replace that date's own (omit to keep them).
192
+ * Applies at once, even on a published event. `quiet` skips the app notice
193
+ * and the staff email (a sync catching up); RSVP emails still go. Errors have
194
+ * a `status`: 400 (not a repeating event, not one of its dates, started or a
195
+ * past time), 401, 404, 409 (a cancelled date, or a taken room for a host).
196
+ */
197
+ export async function moveOccurrence(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/moves').MoveResult> {
198
+ const { moveOccurrence: move } = await import('../lib/calendar/moves');
199
+ return move(internal(repo), who, eventId, occurrenceKey, input, { url: context.url, locals: context.locals }, opts);
200
+ }
201
+
202
+ /** Put a moved date back where the series has it. Same rules; idempotent. */
203
+ export async function moveOccurrenceBack(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/moves').MoveResult> {
204
+ const { moveOccurrenceBack: back } = await import('../lib/calendar/moves');
205
+ return back(internal(repo), who, eventId, occurrenceKey, { url: context.url, locals: context.locals }, opts);
206
+ }
207
+
175
208
  // ---- rooms -----------------------------------------------------------------
176
209
  // Events hold rooms (CalEvent.roomHold) while they happen. These find where
177
210
  // two things want the same room. They don't check permissions, and their