@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
@@ -0,0 +1,291 @@
1
+ // Moved dates (CalEvent.movedDates): one date of a repeating event at another
2
+ // time, and maybe in other rooms or at another place, keeping what makes it
3
+ // that date: its key, its RSVPs (they move with it, and people get the "new
4
+ // time" email in their language with their cancel link), its hosts and its
5
+ // page. Moving and moving back apply at once, even on a published event, and
6
+ // leave staged edits alone, like cancelling a date. A date is moved or
7
+ // cancelled, never both: cancelling a moved date drops the move.
8
+ //
9
+ // These check who is acting, so the admin, the built-in member pages and a
10
+ // site's own code (@acms-subir/core/server) share the rules:
11
+ // - staff ("Create and edit content") and GOOGLE_SYNC (a calendar sync acting
12
+ // with staff rights) may move any date. A room taken at the new time is
13
+ // returned as a warning, since the change may already have happened
14
+ // elsewhere;
15
+ // - a host of the event may move its dates while the rooms are free; a taken
16
+ // room refuses the move (409), and staff are emailed about each host move.
17
+ // `quiet` skips the app notice and the staff email (a sync catching up); the
18
+ // RSVP emails always go, because those people's own booking changed.
19
+ import type { ContentRepository } from '../repo/types';
20
+ import { EventLocation, type CalEvent, type MovedDate } from '../schema';
21
+ import { ApiError } from '../api';
22
+ import { eventSideEffects, type SaveHooks } from '../content-service';
23
+ import { addWallDays, addWallMinutes, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
24
+ import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
25
+ import { kickEmail } from '../email/service';
26
+ import { waitUntilFrom } from '../webhooks';
27
+ import { MEMBERS } from './members-config';
28
+ import { siteLocale } from '../dates';
29
+
30
+ /** The actor for a calendar sync (e.g. Google): staff rights, room clashes only warn. */
31
+ export const GOOGLE_SYNC = 'google-sync';
32
+ /** Who is acting: a person (currentUser() from @acms-subir/core/server fits) or the sync. */
33
+ export type MoveActor = { id: string; can(permission: string): boolean } | typeof GOOGLE_SYNC;
34
+
35
+ export interface MoveInput {
36
+ /** the new start, wall-clock in the event's timezone: YYYY-MM-DDTHH:mm, or YYYY-MM-DD for an all-day event */
37
+ start: string;
38
+ /** the new end in the same form (all-day: the last day, inclusive); default: the date keeps its length */
39
+ end?: string;
40
+ /** the rooms that date holds instead (Resource ids); omit to keep its current rooms */
41
+ roomIds?: string[];
42
+ /** that date's place instead; null = the event's place again; omit to keep its current one */
43
+ location?: Partial<EventLocation> | null;
44
+ /** a public note shown with the date ("Moved for the youth retreat"); omit to keep the current one */
45
+ note?: string;
46
+ }
47
+ export interface MoveOptions {
48
+ /** no app notice and no staff email (a sync catching up); RSVP emails still go */
49
+ quiet?: boolean;
50
+ now?: number;
51
+ }
52
+ export interface MoveResult {
53
+ event: CalEvent;
54
+ /** what else takes the date's rooms at its new time (warnings for staff; hosts are refused instead) */
55
+ clashes: BusySlot[];
56
+ /** false: the date already was exactly so, and nothing was written, sent or emailed */
57
+ changed: boolean;
58
+ }
59
+
60
+ /**
61
+ * Why a move was refused (error.code, with error.status): one-off (not a
62
+ * repeating event), cancelled-event, not-a-date (the key isn't one of its
63
+ * dates), cancelled-date, started (the date has started or passed),
64
+ * original-passed (moving back to a time that has passed), past (the new time
65
+ * has passed), bad-time, room-taken (a host, 409).
66
+ */
67
+ export type MoveRefusal = 'one-off' | 'cancelled-event' | 'not-a-date' | 'cancelled-date' | 'started' | 'original-passed' | 'past' | 'bad-time' | 'room-taken';
68
+ const refuse = (status: number, code: MoveRefusal, message: string) => Object.assign(new ApiError(status, message), { code });
69
+
70
+ const DAY = 86_400_000;
71
+ const NOTE_MAX = 500;
72
+
73
+ type Role = 'staff' | 'host';
74
+ function roleOf(actor: MoveActor | null, e: CalEvent): Role {
75
+ if (actor === GOOGLE_SYNC) return 'staff';
76
+ if (!actor) throw new ApiError(401, 'Sign in to continue.');
77
+ if (actor.can('content.edit')) return 'staff';
78
+ if (e.hostIds.includes(actor.id)) return 'host';
79
+ throw new ApiError(404, 'Event not found.');
80
+ }
81
+ const actorId = (actor: MoveActor) => (actor === GOOGLE_SYNC ? GOOGLE_SYNC : actor.id);
82
+
83
+ async function load(repo: ContentRepository, id: string) {
84
+ const e = await repo.getEvent(id);
85
+ if (!e || e.status === 'archived') throw new ApiError(404, 'Event not found.');
86
+ return e;
87
+ }
88
+
89
+ // the date as it is now (moved or not), checked for moving (`started` waits: an unchanged move is a no-op even then)
90
+ function dateToChange(e: CalEvent, key: string, settings: { timezone: string }): Occurrence {
91
+ if (!e.rrule) throw refuse(400, 'one-off', 'Only a repeating event has single dates to move. Change the event’s time instead.');
92
+ if (e.cancelled) throw refuse(409, 'cancelled-event', 'This event is cancelled.');
93
+ if (typeof key !== 'string' || key.length > 20) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
94
+ if (e.cancelledDates.includes(key)) throw refuse(409, 'cancelled-date', 'That date is cancelled. Restore it before moving it.');
95
+ const o = findOccurrence(e, key, settings);
96
+ if (!o) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
97
+ return o;
98
+ }
99
+ const started = () => refuse(400, 'started', 'That date has already started or passed.');
100
+
101
+ // The new wall times: `start` in the event's form, `end` defaulting to the
102
+ // date's own length (all-day: the same number of days)
103
+ function newTimes(e: CalEvent, input: MoveInput): { start: string; end: string } {
104
+ const s = parseWall(input.start);
105
+ if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
106
+ 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).');
107
+ let end = input.end;
108
+ if (!end) {
109
+ const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
110
+ const length = es && ee ? wallMinutesBetween(es, ee) : 60;
111
+ end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
112
+ }
113
+ try {
114
+ return normalizeEventTimes({ start: input.start, end, allDay: e.allDay });
115
+ } catch (err) {
116
+ throw refuse(400, 'bad-time', err instanceof Error ? err.message : 'Pick a valid time.');
117
+ }
118
+ }
119
+
120
+ // what else takes the rooms this date would hold at its new time (setup and
121
+ // cleanup, or the whole day, as the event holds them)
122
+ async function clashesFor(repo: ContentRepository, next: CalEvent, key: string, at: { start: number; end: number }, settings: { timezone: string }): Promise<BusySlot[]> {
123
+ const resources = new Map((await repo.listResources()).map((r) => [r.id, r]));
124
+ const holds = holdsFromEvents([next], resources, settings, at.start - 2 * DAY, at.end + 2 * DAY, { force: true }).filter((h) => h.key === key);
125
+ const seen = new Set<string>();
126
+ const out: BusySlot[] = [];
127
+ for (const h of holds) {
128
+ for (const b of await roomBusy(repo, settings, { resourceIds: [h.resourceId], from: h.start, to: h.end, ignore: { eventId: next.id, key } })) {
129
+ const id = `${b.resourceId}:${b.kind}:${b.bookingId ?? `${b.eventId}:${b.key}`}:${b.start}`;
130
+ if (!seen.has(id)) { seen.add(id); out.push(b); }
131
+ }
132
+ }
133
+ return out.sort((a, b) => a.start - b.start);
134
+ }
135
+
136
+ async function refuseForHost(repo: ContentRepository, clashes: BusySlot[]) {
137
+ const names = new Map((await repo.listResources()).map((r) => [r.id, r.name]));
138
+ const rooms = [...new Set(clashes.map((c) => names.get(c.resourceId) ?? 'The room'))];
139
+ const list = rooms.length > 1 ? `${rooms.slice(0, -1).join(', ')} and ${rooms[rooms.length - 1]}` : rooms[0];
140
+ throw refuse(409, 'room-taken', `${list} ${rooms.length > 1 ? 'are' : 'is'} taken at that time. Pick another time${rooms.length ? ' or room' : ''}, or ask the team.`);
141
+ }
142
+
143
+ const sameRooms = (a: string[] | undefined, b: string[]) => a !== undefined && a.length === b.length && a.every((x, i) => x === b[i]);
144
+ // the same move, whoever made it and when
145
+ const sameMove = (a: MovedDate, b: MovedDate) => a.start === b.start && a.end === b.end && a.note === b.note
146
+ && JSON.stringify(a.roomIds ?? null) === JSON.stringify(b.roomIds ?? null) && JSON.stringify(a.location ?? null) === JSON.stringify(b.location ?? null);
147
+
148
+ /**
149
+ * Move one date of a repeating event to a new time (and maybe other rooms or
150
+ * another place). Moving an already moved date replaces its move; moving it
151
+ * back to exactly where it was is moveOccurrenceBack. Moving it to where it
152
+ * already is changes nothing (`changed: false`): retries never email twice.
153
+ */
154
+ export async function moveOccurrence(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
155
+ const now = opts.now ?? Date.now();
156
+ const e = await load(repo, id);
157
+ const role = roleOf(actor, e);
158
+ const settings = await repo.getSettings();
159
+ const current = dateToChange(e, key, settings);
160
+ const prior = e.movedDates[key];
161
+ const times = newTimes(e, input);
162
+
163
+ let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
164
+ if (roomIds !== undefined) {
165
+ const known = new Set((await repo.listResources()).map((r) => r.id));
166
+ roomIds = roomIds.filter((r) => known.has(r));
167
+ // the event's own rooms aren't an override
168
+ if (sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
169
+ }
170
+ let location = input.location === undefined ? prior?.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
171
+ if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
172
+ if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
173
+ const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
174
+
175
+ const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: actorId(actor!), at: now };
176
+ if (prior && sameMove(prior, entry)) return { event: e, clashes: [], changed: false };
177
+ const at = movedInstants(e, entry, settings);
178
+ if (!at) throw refuse(400, 'bad-time', 'Pick a valid time.');
179
+
180
+ // back where the series puts it, with nothing else different: that's moving it back
181
+ const series = findOccurrence({ ...e, movedDates: {} }, key, settings)!;
182
+ if (at.start === series.start && at.end === series.end && !roomIds && !location) return moveOccurrenceBack(repo, actor, id, key, hooks, opts);
183
+ if (current.start <= now) throw started();
184
+ if (at.start <= now) throw refuse(400, 'past', 'Pick a time that hasn’t passed yet.');
185
+
186
+ const next: CalEvent = { ...e, movedDates: { ...e.movedDates, [key]: entry } };
187
+ const clashes = await clashesFor(repo, next, key, at, settings);
188
+ if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
189
+
190
+ await repo.setEventMovedDates(id, next.movedDates, undefined, { quiet: opts.quiet });
191
+ const after = (await repo.getEvent(id))!;
192
+ const moved = findOccurrence(after, key, settings)!;
193
+ await afterChange(repo, after, current, moved, { role, actor: actor!, note, hooks, opts, settings });
194
+ return { event: after, clashes, changed: true };
195
+ }
196
+
197
+ /** Put a moved date back where the series has it (idempotent: a date that isn't moved is `changed: false`). Hosts are refused if its rooms are taken there now. */
198
+ export async function moveOccurrenceBack(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
199
+ const now = opts.now ?? Date.now();
200
+ const e = await load(repo, id);
201
+ const role = roleOf(actor, e);
202
+ if (!Object.hasOwn(e.movedDates, key)) return { event: e, clashes: [], changed: false };
203
+ const settings = await repo.getSettings();
204
+ const { [key]: _gone, ...rest } = e.movedDates;
205
+ const next: CalEvent = { ...e, movedDates: rest };
206
+ const current = findOccurrence(e, key, settings);
207
+ const back = findOccurrence(next, key, settings);
208
+ // a key the series no longer has (it was retimed): nothing to put back, just forget it
209
+ if (current && back) {
210
+ if (current.start <= now) throw started();
211
+ if (back.start <= now) throw refuse(400, 'original-passed', 'Its original time has already passed.');
212
+ }
213
+ const clashes = back ? await clashesFor(repo, next, key, back, settings) : [];
214
+ if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
215
+ await repo.setEventMovedDates(id, rest, undefined, { quiet: opts.quiet });
216
+ const after = (await repo.getEvent(id))!;
217
+ if (current && back) await afterChange(repo, after, current, back, { role, actor: actor!, note: '', hooks, opts, settings });
218
+ else await eventSideEffects(repo, hooks, after);
219
+ return { event: after, clashes, changed: true };
220
+ }
221
+
222
+ // RSVPs follow the date (emailed when its time changed), then the usual side
223
+ // effects (webhook; the app notice unless the write was quiet), then staff hear about a host's move
224
+ async function afterChange(repo: ContentRepository, after: CalEvent, from: Occurrence, to: Occurrence,
225
+ c: { role: Role; actor: MoveActor; note: string; hooks: SaveHooks; opts: MoveOptions; settings: Awaited<ReturnType<ContentRepository['getSettings']>> }) {
226
+ if ((from.start !== to.start || from.end !== to.end) && await repo.moveOccurrenceRsvps(after.id, from.start, { start: to.start, end: to.end }, c.note)) {
227
+ await kickEmail(repo, c.hooks.locals, waitUntilFrom(c.hooks.locals));
228
+ }
229
+ await eventSideEffects(repo, c.hooks, after);
230
+ if (c.role === 'host' && !c.opts.quiet && c.actor !== GOOGLE_SYNC) await notifyStaffOfMove(repo, after, from, to, c.actor.id, c.note, c.settings, c.hooks);
231
+ }
232
+
233
+ // "Date moved" to members.notify (else the owners), like members' requests
234
+ async function notifyStaffOfMove(repo: ContentRepository, e: CalEvent, from: Occurrence, to: Occurrence, by: string, note: string,
235
+ settings: Awaited<ReturnType<ContentRepository['getSettings']>>, hooks: SaveHooks) {
236
+ const recipients = MEMBERS.notify.length ? MEMBERS.notify : (await repo.listUsers()).filter((u) => u.role === 'owner' && u.email).map((u) => u.email);
237
+ if (!recipients.length) return;
238
+ const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
239
+ const fmt = (o: Occurrence) => {
240
+ try { return new Intl.DateTimeFormat(siteLocale(settings), e.allDay ? { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(o.start); }
241
+ catch { return new Date(o.start).toISOString(); }
242
+ };
243
+ const version = (await repo.email.config().catch(() => null))?.version ?? 0;
244
+ const at = Date.now();
245
+ for (const recipient of recipients) {
246
+ await repo.email.enqueue({ dedupe: `event-move:${e.id}:${from.key}:${at}:${recipient}`, kind: 'event-request', recipient,
247
+ payload: JSON.stringify({ eventId: e.id, kind: 'staff-moved', at, reply: '', by, note, when: `${fmt(from)} → ${fmt(to)}` }), configVersion: version, expiresAt: at + 7 * DAY });
248
+ }
249
+ await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals)).catch(() => {});
250
+ }
251
+
252
+ /** One upcoming date for the editor's Dates panel: its key, wall times (for the move form), words, and state. */
253
+ export interface DateRow {
254
+ key: string;
255
+ /** where it is now, wall-clock in the event's zone (a moved date: its new time) */
256
+ start: string;
257
+ end: string;
258
+ /** "Thu, Jan 10 · 6:00 – 7:00 PM" */
259
+ when: string;
260
+ cancelled: boolean;
261
+ /** moved: "Thu, Jan 10 · 6:00 PM" (where it was) and the note */
262
+ moved: { from: string; note: string } | null;
263
+ roomIds: string[];
264
+ }
265
+
266
+ /** The next `limit` dates of a series from `now`, moved and cancelled ones included. */
267
+ export function upcomingDates(e: CalEvent, settings: Awaited<ReturnType<ContentRepository['getSettings']>>, limit: number, now = Date.now()): DateRow[] {
268
+ if (!e.rrule) return [];
269
+ const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
270
+ const locale = siteLocale(settings);
271
+ const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
272
+ const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
273
+ const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
274
+ const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
275
+ const length = es && ee ? wallMinutesBetween(es, ee) : 60;
276
+ return expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
277
+ .slice(0, limit)
278
+ .map((o) => {
279
+ const m = o.moved ? e.movedDates[o.key] : undefined;
280
+ const k = parseWall(o.key)!;
281
+ return {
282
+ key: o.key,
283
+ start: m?.start ?? o.key,
284
+ end: m?.end ?? formatWall(e.allDay ? addWallDays(k, Math.round(length / 1440)) : addWallMinutes(k, length)),
285
+ when: e.allDay ? day.format(o.start) : `${day.format(o.start)} · ${range(o.start, o.end)}`,
286
+ cancelled: o.cancelled,
287
+ moved: o.moved ? { from: e.allDay ? day.format(o.moved.from.start) : `${day.format(o.moved.from.start)} · ${time.format(o.moved.from.start)}`, note: o.moved.note } : null,
288
+ roomIds: o.roomIds,
289
+ };
290
+ });
291
+ }
@@ -12,6 +12,7 @@ import { getCalendarEvents, tzParts, zonedToUtc } from './ics';
12
12
  import { calendarPageUrl } from './src';
13
13
  import { collectViewEvents, type ViewEvent } from './data';
14
14
  import { eventStrings, type EventStrings } from './event-words';
15
+ import { fill } from '../live/words';
15
16
 
16
17
  // Card sources are ICS feed URLs or native site calendars:
17
18
  // 'acms:all' every public calendar (+ uncategorized events)
@@ -58,6 +59,7 @@ interface DisplayEvent {
58
59
  u?: string; // native events: the event page
59
60
  k?: string; // calendar color (#rrggbb), overrides the indexed dot color
60
61
  x?: 1; // cancelled
62
+ mv?: string; // a moved date: "Moved from Thu, Jan 10 · 6:00 PM."
61
63
  }
62
64
 
63
65
  function compactTime(s: string): string {
@@ -65,7 +67,8 @@ function compactTime(s: string): string {
65
67
  }
66
68
 
67
69
  function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayEvent[] {
68
- const allDay = eventStrings(locale).allDay;
70
+ const ew = eventStrings(locale);
71
+ const allDay = ew.allDay;
69
72
  const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
70
73
  const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
71
74
  return events.map((e) => {
@@ -85,6 +88,7 @@ function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayE
85
88
  ...(e.url ? { u: e.url } : {}),
86
89
  ...(e.color && /^#[0-9a-f]{6}$/i.test(e.color) ? { k: e.color } : {}),
87
90
  ...(e.cancelled ? { x: 1 as const } : {}),
91
+ ...(e.moved && !e.cancelled ? { mv: fill(ew.dateMoved, { from: e.allDay ? dayFmt.format(e.moved.from.start) : `${dayFmt.format(e.moved.from.start)} · ${timeFmt.format(e.moved.from.start)}` }) } : {}),
88
92
  };
89
93
  });
90
94
  }
@@ -105,7 +109,7 @@ function indexByDay(events: DisplayEvent[]): Map<number, number[]> {
105
109
 
106
110
  const dotStyle = (e: DisplayEvent) => (e.k ? ` style="--cal-dot:${e.k}"` : '');
107
111
  const chipHtml = (e: DisplayEvent, i: number) =>
108
- `<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
112
+ `<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}${e.mv ? ' is-moved' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
109
113
 
110
114
  function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[], ws: number): string {
111
115
  const first = dayNum(y, m, 1);
@@ -137,7 +141,7 @@ function upcomingHtml(events: DisplayEvent[], order: number[], limit: number, w:
137
141
  if (!order.length) return `<p class="pc-cal-empty">${esc(w.calendar.noUpcoming)}</p>`;
138
142
  const items = order.slice(0, limit).map((i) => {
139
143
  const e = events[i];
140
- return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || esc(w.allDay)}</span></button></li>`;
144
+ return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}${e.mv ? ' is-moved' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || esc(w.allDay)}</span></button></li>`;
141
145
  });
142
146
  return `<ul class="pc-cal-agenda">${items.join('')}</ul>`;
143
147
  }
@@ -154,7 +158,7 @@ function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1
154
158
  function dow(n){return((n+4-(P.ws||0))%7+7)%7}
155
159
  var byDay={};P.events.forEach(function(e,i){var n0=dnum(e.d);for(var k=0;k<Math.min(e.n,60);k++){(byDay[n0+k]=byDay[n0+k]||[]).push(i)}});
156
160
  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
157
- function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
161
+ function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+(e.mv?' is-moved':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
158
162
  function chips(n){return(byDay[n]||[]).map(chip).join('')}
159
163
  var todayN=dnum(P.today),mY=P.month[0],mM=P.month[1],weekN=todayN-dow(todayN);
160
164
  function renderMonth(){
@@ -190,7 +194,7 @@ if(!hit||!root.contains(hit)){if(!t.closest('.pc-cal-pop'))closePop();return}
190
194
  var e=P.events[Number(hit.getAttribute('data-ev'))];if(!e)return;
191
195
  closePop();
192
196
  pop=document.createElement('div');pop.className='pc-cal-pop';
193
- pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="'+esc(P.close)+'">&times;</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+'<strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' &rarr;</a>':'');
197
+ pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="'+esc(P.close)+'">&times;</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+(e.mv?'<span class="pc-cal-pop-flag is-moved">'+esc(P.moved)+'</span>':'')+'<strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.mv?'<div class="pc-cal-pop-moved">'+esc(e.mv)+'</div>':'')+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' &rarr;</a>':'');
194
198
  frame.appendChild(pop);
195
199
  var fr=frame.getBoundingClientRect(),cr=hit.getBoundingClientRect();
196
200
  pop.style.left=Math.max(8,Math.min(cr.left-fr.left,fr.width-pop.offsetWidth-8))+'px';
@@ -295,7 +299,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
295
299
  max: [maxY, maxM],
296
300
  months, monthsShort, dows, events, ws,
297
301
  // the pop-up's words in the dates' language, like event cards
298
- details: w.calendar.details, cancelled: w.cancelled, close: w.calendar.close,
302
+ details: w.calendar.details, cancelled: w.cancelled, close: w.calendar.close, moved: w.moved,
299
303
  };
300
304
  const json = JSON.stringify(payload).replace(/</g, '\\u003c');
301
305
 
@@ -1,17 +1,19 @@
1
- // Writes behind the admin Schedule: quick add, drag to move (a whole series
2
- // or one occurrence), undo, duplicate. Everything goes through the same
1
+ // Writes behind the admin Schedule: quick add, drag to move (a whole series,
2
+ // or one date: a moved date, lib/calendar/moves.ts), undo, duplicate. Everything goes through the same
3
3
  // save/apply path as the event editor, so hooks, feeds, search and webhooks
4
4
  // see the same changes. A move applies at once, even on a published event —
5
5
  // the editor's "stage until Update" flow is for content edits, not dates.
6
6
  // Cancelling one date of a series applies at once too, without touching any
7
7
  // staged edits.
8
8
  import type { ContentRepository } from '../repo/types';
9
- import type { CalEvent } from '../schema';
9
+ import type { CalEvent, MovedDate } from '../schema';
10
10
  import { EventLocation, slugify } from '../schema';
11
11
  import { defaultPlaceLocation } from '../maps/places';
12
12
  import { ApiError } from '../api';
13
13
  import { applyEventUpdate, eventSideEffects, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
14
- import { addWallDays, findOccurrence, formatWall, parseWall } from './events';
14
+ import { addWallDays, addWallMinutes, findOccurrence, formatWall, parseWall, wallMinutesBetween } from './events';
15
+ import { moveOccurrence, moveOccurrenceBack, type MoveActor } from './moves';
16
+ import type { BusySlot } from '../booking/holds';
15
17
  import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
16
18
  import { waitUntilFrom } from '../webhooks';
17
19
  import { kickEmail } from '../email/service';
@@ -91,78 +93,80 @@ export function shiftRrule(rrule: string, days: number, newStart: string): strin
91
93
  return Object.entries(p).sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([k, v]) => `${k}=${v}`).join(';');
92
94
  }
93
95
 
94
- const shift = (wall: string, days: number) => {
96
+ // a wall time moved by whole minutes (date-only walls by whole days)
97
+ const shiftBy = (wall: string, minutes: number) => {
95
98
  const w = parseWall(wall);
96
- return w ? formatWall(addWallDays(w, days)) : wall;
99
+ return w ? formatWall(addWallMinutes(w, w.dateOnly ? Math.round(minutes / 1440) * 1440 : minutes)) : wall;
100
+ };
101
+ const dayOf = (wall: string) => {
102
+ const w = parseWall(wall);
103
+ return w ? { ...w, hh: 0, mm: 0, dateOnly: true } : null;
97
104
  };
98
105
 
99
106
  export type MoveUndo =
100
- | { kind: 'series'; id: string; days: number }
107
+ | { kind: 'series'; id: string; days: number; minutes?: number }
108
+ // one date moved (previous: where it was moved before, null = not moved)
109
+ | { kind: 'date'; id: string; key: string; previous: Pick<MovedDate, 'start' | 'end' | 'roomIds' | 'location' | 'note'> | null }
110
+ // undo tokens from before moved dates: the date was split off into a copy
101
111
  | { kind: 'split'; id: string; seriesId: string; occurrence: string };
102
112
 
103
- // Move a whole event (and every repeat) by `days`
104
- export async function moveSeries(repo: ContentRepository, id: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
113
+ // Move a whole event (and every repeat) by `days`, and `minutes` more (a drag
114
+ // in the week view; all-day events move by whole days only)
115
+ export async function moveSeries(repo: ContentRepository, id: string, days: number, hooks: SaveHooks, minutes = 0): Promise<{ event: CalEvent; undo: MoveUndo }> {
105
116
  const e = await repo.getEvent(id);
106
117
  if (!e) throw new ApiError(404, 'Event not found.');
107
- if (!Number.isInteger(days) || Math.abs(days) > 3660) throw new ApiError(400, 'Pick a day to move it to.');
108
- const start = shift(e.start, days);
118
+ if (!Number.isInteger(days) || Math.abs(days) > 3660 || !Number.isInteger(minutes) || Math.abs(minutes) > 1440 * 7) throw new ApiError(400, 'Pick a day to move it to.');
119
+ const total = days * 1440 + (e.allDay ? 0 : minutes);
120
+ const start = shiftBy(e.start, total);
121
+ // the rule's weekdays follow the start's new day
122
+ const from = dayOf(e.start), to = dayOf(start);
123
+ const dayShift = from && to ? Math.round(wallMinutesBetween(from, to) / 1440) : days;
109
124
  let event = await saveNow(repo, e, {
110
- start, end: shift(e.end, days), allDay: e.allDay,
111
- ...(e.rrule ? { rrule: shiftRrule(e.rrule, days, start), exdates: e.exdates.map((x) => shift(x, days)) } : {}),
125
+ start, end: shiftBy(e.end, total), allDay: e.allDay,
126
+ ...(e.rrule ? { rrule: shiftRrule(e.rrule, dayShift, start), exdates: e.exdates.map((x) => shiftBy(x, total)) } : {}),
112
127
  }, hooks);
113
- // cancelled dates move with the series, like skipped ones
114
- if (e.rrule && e.cancelledDates.length) {
115
- await repo.setEventCancelledDates(id, e.cancelledDates.map((x) => shift(x, days)));
128
+ // cancelled dates move with the series, like skipped ones; a moved date
129
+ // keeps its new time and follows under its new key
130
+ if (e.rrule && (e.cancelledDates.length || Object.keys(e.movedDates).length)) {
131
+ const moved = Object.fromEntries(Object.entries(e.movedDates).map(([k, m]) => [shiftBy(k, total), m]));
132
+ await repo.setEventMovedDates(id, moved, e.cancelledDates.map((x) => shiftBy(x, total)));
116
133
  await flushEventHooks(repo, hooks);
117
134
  event = (await repo.getEvent(id))!;
118
135
  }
119
- return { event, undo: { kind: 'series', id, days: -days } };
136
+ return { event, undo: { kind: 'series', id, days: -days, minutes: -minutes } };
120
137
  }
121
138
 
122
- // Move one occurrence of a repeating event: skip that date in the series and
123
- // add a one-off copy on the new date. No schema change, and the copy is a
124
- // normal event the editor can open. The copy keeps the series' hosts, room
125
- // hold and translations, and that date's RSVPs move to it (people get the
126
- // "new time" email).
127
- export async function moveOccurrence(repo: ContentRepository, id: string, occurrence: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
139
+ // Move one date of a repeating event by `minutes` (a drag on the Schedule):
140
+ // a stored move (lib/calendar/moves.ts) that keeps its key, RSVPs and hosts,
141
+ // and its length. A date already moved moves on from where it is. A one-off
142
+ // event moves as a whole.
143
+ export async function moveDateBy(repo: ContentRepository, actor: MoveActor, id: string, key: string, minutes: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo; clashes: BusySlot[] }> {
128
144
  const e = await repo.getEvent(id);
129
145
  if (!e) throw new ApiError(404, 'Event not found.');
130
- if (!e.rrule) return moveSeries(repo, id, days, hooks);
131
- const occ = parseWall(occurrence);
132
- const first = parseWall(e.start);
133
- if (!occ || !first) throw new ApiError(400, 'That date isn’t part of this event.');
134
- if (e.exdates.includes(occurrence)) throw new ApiError(409, 'That date was already removed from the series.');
135
- // keep the occurrence's own length (first occurrence's start → end)
136
- const toMs = (w: { y: number; m: number; d: number; hh: number; mm: number }) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm);
137
- const lenMs = toMs(parseWall(e.end) ?? first) - toMs(first);
138
- const newStart = addWallDays(occ, days);
139
- const t = new Date(toMs(newStart) + lenMs);
140
- const pad = (n: number) => String(n).padStart(2, '0');
141
- const endDay = `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
142
- const endStr = e.allDay ? endDay : `${endDay}T${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}`;
143
-
144
- const settings = await repo.getSettings();
145
- const was = findOccurrence(e, occurrence, settings);
146
- await saveNow(repo, e, { exdates: [...e.exdates, occurrence] }, hooks);
147
- const copy = await repo.createEvent({
148
- title: e.title, slug: await uniqueSlug(repo, e.title), start: formatWall(newStart), end: endStr, allDay: e.allDay,
149
- timezone: e.timezone, status: 'draft', excerpt: e.excerpt, bodyJson: e.bodyJson, bodyHtml: e.bodyHtml, cover: e.cover, coverAlt: e.coverAlt,
150
- seoTitle: e.seoTitle, seoDesc: e.seoDesc, location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags,
151
- // a cancelled date stays cancelled on its new day
152
- cancelled: e.cancelled || e.cancelledDates.includes(occurrence), rsvp: e.rsvp,
153
- template: e.template, featured: false, roomHold: e.roomHold, hostIds: e.hostIds, translations: e.translations,
154
- });
155
- const event = live(e.status) ? await saveEvent(repo, copy.id, { status: 'published' }, hooks) : copy;
156
- const now = findOccurrence(event, event.start, settings);
157
- if (was && now && await repo.moveOccurrenceRsvps(e.id, was.start, { eventId: event.id, start: now.start, end: now.end })) {
158
- await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
146
+ if (!e.rrule) {
147
+ const r = await moveSeries(repo, id, Math.trunc(minutes / 1440), hooks, minutes % 1440);
148
+ return { ...r, clashes: [] };
159
149
  }
160
- return { event, undo: { kind: 'split', id: copy.id, seriesId: e.id, occurrence } };
150
+ if (!Number.isInteger(minutes) || Math.abs(minutes) > 1440 * 3660) throw new ApiError(400, 'Pick a day to move it to.');
151
+ const prior = e.movedDates[key];
152
+ const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
153
+ const k = parseWall(key);
154
+ if (!k || !es || !ee) throw new ApiError(400, 'That date isn’t part of this event.');
155
+ const startWall = prior?.start ?? key;
156
+ const endWall = prior?.end ?? formatWall(addWallMinutes(k, wallMinutesBetween(es, ee)));
157
+ const r = await moveOccurrence(repo, actor, id, key, { start: shiftBy(startWall, minutes), end: shiftBy(endWall, minutes) }, hooks);
158
+ const previous = prior ? { start: prior.start, end: prior.end, roomIds: prior.roomIds, location: prior.location, note: prior.note } : null;
159
+ return { event: r.event, clashes: r.clashes, undo: { kind: 'date', id, key, previous } };
161
160
  }
162
161
 
163
- export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
162
+ export async function undoMove(repo: ContentRepository, actor: MoveActor, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
164
163
  if (undo.kind === 'series') {
165
- await moveSeries(repo, undo.id, undo.days, hooks);
164
+ await moveSeries(repo, undo.id, undo.days, hooks, undo.minutes ?? 0);
165
+ return;
166
+ }
167
+ if (undo.kind === 'date') {
168
+ if (undo.previous) await moveOccurrence(repo, actor, undo.id, undo.key, { ...undo.previous, roomIds: undo.previous.roomIds ?? [], location: undo.previous.location ?? null }, hooks);
169
+ else await moveOccurrenceBack(repo, actor, undo.id, undo.key, hooks);
166
170
  return;
167
171
  }
168
172
  let series = await repo.getEvent(undo.seriesId);
@@ -185,21 +189,29 @@ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: S
185
189
  // The date stays on the calendar struck through ("Cancelled"), its room holds
186
190
  // are released, feeds mark it STATUS:CANCELLED, and its RSVPs are cancelled
187
191
  // (each person gets the "cancelled" email; no reminder). Restoring the date
188
- // leaves those RSVPs cancelled: people sign up again. These do no permission
192
+ // leaves those RSVPs cancelled: people sign up again. Cancelling a moved date
193
+ // cancels it where it was moved to, and drops the move. These do no permission
189
194
  // checks: callers (admin actions, site code through @acms-subir/core/server)
190
195
  // decide who may do this.
191
196
 
192
197
  async function setOccurrenceCancelled(repo: ContentRepository, id: string, key: string, cancel: boolean, hooks: SaveHooks, now = Date.now()): Promise<CalEvent> {
193
198
  const e = await repo.getEvent(id);
194
199
  if (!e) throw new ApiError(404, 'Event not found.');
195
- if (!e.rrule) throw new ApiError(400, 'Only a repeating event has single dates to cancel. Cancel the whole event instead.');
200
+ // error.code as for moves (lib/calendar/moves.ts MoveRefusal)
201
+ const refuse = (code: string, message: string) => Object.assign(new ApiError(400, message), { code });
202
+ if (!e.rrule) throw refuse('one-off', 'Only a repeating event has single dates to cancel. Cancel the whole event instead.');
196
203
  const settings = await repo.getSettings();
197
204
  const o = typeof key === 'string' && key.length <= 20 ? findOccurrence(e, key, settings) : null;
198
- if (!o) throw new ApiError(400, 'That date isn’t part of this event.');
199
- if (o.start <= now) throw new ApiError(400, 'That date has already started or passed.');
205
+ if (!o) throw refuse('not-a-date', 'That date isn’t part of this event.');
200
206
  const has = e.cancelledDates.includes(key);
201
- if (has === cancel) return e; // already so: nothing to change
202
- await repo.setEventCancelledDates(id, cancel ? [...e.cancelledDates, key].sort() : e.cancelledDates.filter((x) => x !== key));
207
+ if (has === cancel) return e; // already so: nothing to change (also once it has started)
208
+ if (o.start <= now) throw refuse('started', 'That date has already started or passed.');
209
+ const cancelledDates = cancel ? [...e.cancelledDates, key].sort() : e.cancelledDates.filter((x) => x !== key);
210
+ // a date is moved or cancelled, never both: cancelling a moved date drops its move (its RSVPs, at the new time, are cancelled below)
211
+ if (cancel && Object.hasOwn(e.movedDates, key)) {
212
+ const { [key]: _moved, ...moved } = e.movedDates;
213
+ await repo.setEventMovedDates(id, moved, cancelledDates);
214
+ } else await repo.setEventCancelledDates(id, cancelledDates);
203
215
  if (cancel && await repo.cancelOccurrenceRsvps(id, o.start)) await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
204
216
  const after = (await repo.getEvent(id))!;
205
217
  await eventSideEffects(repo, hooks, after);
@@ -79,7 +79,8 @@ const STRINGS: Record<string, BookingStrings> = {
79
79
  offered: { subject: 'A spot opened: {title}', heading: 'A spot opened for {title}', intro: 'We’re holding it for you until {until}. Confirm it before then, or it goes to the next person in line.' },
80
80
  left: { subject: 'Off the waitlist: {title}', heading: 'You’re off the waitlist', intro: 'You’re no longer on the waitlist for this date.' },
81
81
  },
82
- manage: 'View or change your booking', rsvp: { manage: 'View or change your booking' },
82
+ manage: 'View or change your booking',
83
+ rsvp: { manage: 'View or change your booking' },
83
84
  waitlist: { manage: 'See your place or leave the waitlist', confirm: 'Confirm my spot' },
84
85
  what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
85
86
  lateDecline: 'We weren’t able to confirm this booking in time. Please pick another time.',
@@ -118,6 +119,8 @@ const STRINGS: Record<string, BookingStrings> = {
118
119
  manage: 'Ver o cambiar su reservación',
119
120
  rsvp: {
120
121
  manage: 'Ver o cambiar su confirmación',
122
+ // one date of the event moved (its RSVPs go with it); staff's note follows
123
+ rescheduled: { subject: 'Nuevo horario: {title} — {when}', heading: 'Esta fecha cambió', intro: 'Tiene un nuevo horario y su lugar se mantiene. Importe el archivo de calendario adjunto para actualizar su calendario, o cancele con el botón de abajo si no puede asistir.' },
121
124
  confirmed: { subject: 'Confirmado: {title} — {when}', heading: 'Ya tiene su lugar', intro: 'Está confirmado. Importe el archivo adjunto para agregarlo a su calendario.' },
122
125
  declined: { intro: 'Lo sentimos, el organizador no pudo confirmar su lugar.' },
123
126
  cancelled: { subject: 'Cancelado: {title}', heading: 'Confirmación cancelada', intro: 'Esta confirmación se canceló.' },
@@ -163,6 +166,7 @@ const STRINGS: Record<string, BookingStrings> = {
163
166
  manage: 'Voir ou modifier votre réservation',
164
167
  rsvp: {
165
168
  manage: 'Voir ou modifier votre inscription',
169
+ rescheduled: { subject: 'Nouvel horaire : {title} — {when}', heading: 'Cette date a changé', intro: 'Elle a un nouvel horaire et votre place est conservée. Importez le fichier de calendrier joint pour mettre à jour votre calendrier, ou annulez avec le bouton ci-dessous si vous ne pouvez pas venir.' },
166
170
  confirmed: { heading: 'Votre place est réservée' },
167
171
  declined: { intro: 'Désolés, l’organisateur n’a pas pu confirmer votre inscription.' },
168
172
  cancelled: { heading: 'Inscription annulée', intro: 'Cette inscription a été annulée.' },
@@ -208,6 +212,7 @@ const STRINGS: Record<string, BookingStrings> = {
208
212
  manage: 'Buchung ansehen oder ändern',
209
213
  rsvp: {
210
214
  manage: 'Anmeldung ansehen oder ändern',
215
+ rescheduled: { subject: 'Neuer Termin: {title} — {when}', heading: 'Dieser Termin wurde verschoben', intro: 'Er hat eine neue Uhrzeit, und Ihr Platz bleibt reserviert. Importieren Sie die angehängte Kalenderdatei, um Ihren Kalender zu aktualisieren, oder sagen Sie über die Schaltfläche unten ab, wenn Sie nicht kommen können.' },
211
216
  confirmed: { heading: 'Ihr Platz ist reserviert' },
212
217
  declined: { intro: 'Leider konnte der Veranstalter Ihre Anmeldung nicht bestätigen.' },
213
218
  cancelled: { heading: 'Anmeldung storniert', intro: 'Diese Anmeldung wurde storniert.' },
@@ -253,6 +258,7 @@ const STRINGS: Record<string, BookingStrings> = {
253
258
  manage: 'Ver ou alterar sua reserva',
254
259
  rsvp: {
255
260
  manage: 'Ver ou alterar sua inscrição',
261
+ rescheduled: { subject: 'Novo horário: {title} — {when}', heading: 'Esta data mudou', intro: 'Ela tem um novo horário e sua vaga foi mantida. Importe o arquivo de calendário anexo para atualizar seu calendário ou cancele pelo botão abaixo se não puder ir.' },
256
262
  confirmed: { heading: 'Sua vaga está reservada' },
257
263
  declined: { intro: 'Desculpe, o organizador não pôde confirmar sua inscrição.' },
258
264
  cancelled: { heading: 'Inscrição cancelada', intro: 'Esta inscrição foi cancelada.' },
@@ -47,9 +47,14 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
47
47
  return accountMail(payload.purpose, job.recipient, settings.siteTitle, `${origin}/account/${payload.purpose}?token=${encodeURIComponent(raw)}`);
48
48
  }
49
49
  if (job.kind === 'event-request') {
50
- const p = JSON.parse(job.payload) as { eventId: string; kind: 'staff-new' | 'staff-changes' | 'approved' | 'declined'; at: number; reply: string };
50
+ const p = JSON.parse(job.payload) as { eventId: string; kind: 'staff-new' | 'staff-changes' | 'staff-moved' | 'approved' | 'declined'; at: number; reply: string; by?: string; note?: string; when?: string };
51
51
  const e = await repo.getEvent(p.eventId);
52
52
  if (!e) return null;
53
+ // a host moved one date (lib/calendar/moves.ts): the words were fixed when it happened
54
+ if (p.kind === 'staff-moved') {
55
+ const by = p.by ? await repo.getUser(p.by) : null;
56
+ return eventRequestMail('staff-moved', job.recipient, settings.siteTitle, { title: e.title, when: p.when ?? '', by: by?.name ?? '', note: p.note ?? '' }, '', `${origin}/admin/calendar/events/${e.id}`);
57
+ }
53
58
  const staff = p.kind.startsWith('staff');
54
59
  // staff hear only about requests still waiting (not withdrawn or already answered)
55
60
  if (staff && (e.request?.state !== 'pending' || e.request.at !== p.at)) return null;