@acms-subir/core 0.3.46 → 0.3.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/cli/migrations.generated.mjs +42 -23
- package/docs/calendar.md +80 -0
- package/docs/server.md +28 -3
- package/docs/theme-authoring.md +1 -1
- package/package.json +1 -1
- package/src/actions/index.ts +76 -11
- package/src/components/EventPage.astro +18 -12
- package/src/components/calendar/CalendarBoard.tsx +43 -19
- package/src/components/editor/event/EventSheet.tsx +2 -0
- package/src/components/editor/event/SingleDates.tsx +123 -0
- package/src/lib/booking/holds.ts +7 -7
- package/src/lib/calendar/api.ts +11 -1
- package/src/lib/calendar/board.ts +7 -3
- package/src/lib/calendar/cards.ts +14 -11
- package/src/lib/calendar/change-migration.ts +40 -15
- package/src/lib/calendar/data.ts +34 -12
- package/src/lib/calendar/event-words.ts +133 -0
- package/src/lib/calendar/events.ts +94 -9
- package/src/lib/calendar/feeds.ts +11 -3
- package/src/lib/calendar/filter.ts +140 -0
- package/src/lib/calendar/ics-write.ts +25 -2
- package/src/lib/calendar/moves.ts +291 -0
- package/src/lib/calendar/render.ts +26 -18
- package/src/lib/calendar/schedule.ts +73 -61
- package/src/lib/email/booking-words.ts +7 -1
- package/src/lib/email/service.ts +6 -1
- package/src/lib/email/templates.ts +6 -2
- package/src/lib/mobile/calendar.ts +32 -14
- package/src/lib/mobile/http.ts +5 -3
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/push/service.ts +44 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +28 -10
- package/src/lib/repo/types.ts +6 -3
- package/src/lib/schema.ts +21 -0
- package/src/lib/sections.ts +5 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/api/calendar/events/index.ts +24 -12
- package/src/pages/calendar/[slug].ics.ts +8 -2
- package/src/pages/calendar/feed.ics.ts +9 -2
- package/src/pages/calendar/index.astro +104 -48
- package/src/pages/embed/calendar/[slug].astro +29 -9
- package/src/pages/members/_Layout.astro +1 -0
- package/src/pages/members/events/index.astro +1 -1
- package/src/server/index.ts +33 -0
- package/src/styles/editor.css +19 -0
- package/src/styles/schedule.css +2 -0
- package/src/styles/site.css +16 -0
- package/src/theme-sdk/calendar.ts +3 -2
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
// Writes behind the admin Schedule: quick add, drag to move (a whole series
|
|
2
|
-
// or one
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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:
|
|
111
|
-
...(e.rrule ? { rrule: shiftRrule(e.rrule,
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
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)
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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',
|
|
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.' },
|
package/src/lib/email/service.ts
CHANGED
|
@@ -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;
|
|
@@ -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
|
-
/**
|
|
16
|
-
|
|
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];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import {
|
|
3
|
+
import { type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
|
|
6
6
|
import { calendarToJson, eventToJson, roomsToJson } from '../calendar/api';
|
|
@@ -17,6 +17,7 @@ import { localizeDoc } from '../translations';
|
|
|
17
17
|
import { localeQuery, mobileLocale, localizedUrl } from './locale';
|
|
18
18
|
import { confirmsInstantly, linkApproval } from '../booking/approval';
|
|
19
19
|
import { changeTopics } from '../push/service';
|
|
20
|
+
import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../calendar/filter';
|
|
20
21
|
|
|
21
22
|
export function requireCalendar(bookings = false) {
|
|
22
23
|
if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
|
|
@@ -27,14 +28,24 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
|
|
|
27
28
|
const origin = siteOrigin(await repo.getSettings(), url);
|
|
28
29
|
return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
|
|
29
30
|
}
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// The website calendar's filters (calendar/filter.ts). Each takes one or more
|
|
32
|
+
// values, comma-separated or repeated. Tags are a public tag's name or slug
|
|
33
|
+
// (internal "#" tags never match, as they are never shown).
|
|
34
|
+
const FILTER_LISTS = ['calendar', 'cal', 'tag', 'excludeTag', 'notag', 'room'] as const;
|
|
35
|
+
const filterList = (what: string) => z.string().min(1).max(2100)
|
|
36
|
+
.refine((v) => { const parts = v.split(','); return parts.length <= 20 && parts.every((p) => p.trim().length <= 100); }, 'Use up to 20 values of up to 100 characters each.')
|
|
37
|
+
.optional().describe(`${what} One or more, comma-separated (a,b) or repeated.`);
|
|
38
|
+
export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(),
|
|
39
|
+
calendar: filterList('Only events in these calendars (slugs).'), cal: filterList('Alias of calendar.'),
|
|
40
|
+
tag: filterList('Only events with one of these tags (a name in any case, or a slug).'), excludeTag: filterList('Leave out events with any of these tags.'), notag: filterList('Alias of excludeTag.'),
|
|
41
|
+
room: filterList('Only events held in one of these rooms (slugs of rooms shown on public events).'),
|
|
42
|
+
q: z.string().max(100).optional() }).strict();
|
|
33
43
|
export async function listEvents(repo: ContentRepository, url: URL) {
|
|
34
44
|
requireCalendar();
|
|
35
|
-
const q = eventsQuery.parse(queryParams(url));
|
|
45
|
+
const q = eventsQuery.parse(queryParams(url, FILTER_LISTS));
|
|
36
46
|
const locale = mobileLocale(q.locale);
|
|
37
47
|
const term = q.q?.trim() ?? '';
|
|
48
|
+
const filter = readEventFilter(url.searchParams);
|
|
38
49
|
const [settings, calendars, events, resources, indexed] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), roomMap(repo),
|
|
39
50
|
// the search index's matches (body text, word prefixes), so "more" from /search continues here
|
|
40
51
|
term ? repo.searchPublished(term, { limit: 200, collections: [] }).then((r) => new Set(r.events.map(({ doc }) => doc.id))) : null]);
|
|
@@ -47,14 +58,18 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
47
58
|
const to = q.to ? at(q.to) : fromDay.length === 10 ? localMidnight(addDaysYmd(fromDay, 30), tz) : from + 30 * 86400000;
|
|
48
59
|
if (to <= from || to - from > 90 * 86400000 + 3600000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
|
|
49
60
|
const all = new Map(calendars.map(c => [c.id, c]));
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
61
|
+
// private calendars and rooms not shown on public events answer like missing ones
|
|
62
|
+
const f = resolveEventFilter(filter, { calendars, resources: [...resources.values()] });
|
|
63
|
+
if (f.calendarIds && f.calendarIds.length < filter.cal.length) throw new ApiError(404, 'Calendar not found.');
|
|
64
|
+
if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Room not found.');
|
|
65
|
+
const picked = f.calendarIds;
|
|
66
|
+
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || picked?.includes(c.id)).map(c => [c.id, c]));
|
|
67
|
+
const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (picked ? e.calendarIds.some(id => picked.includes(id)) && eventIsPublic(e, all) : eventIsListed(e, all))
|
|
68
|
+
&& tagsMatch(f, e.tags)
|
|
56
69
|
&& (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
|
|
57
|
-
const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to })
|
|
70
|
+
const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to })
|
|
71
|
+
// rooms are per date (a moved date can change them)
|
|
72
|
+
.filter(o => !f.roomIds || occurrenceMatches(f, o));
|
|
58
73
|
occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
|
|
59
74
|
const start = (q.page - 1) * q.limit;
|
|
60
75
|
const origin = siteOrigin(settings, url);
|
|
@@ -91,9 +106,12 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
|
|
|
91
106
|
}
|
|
92
107
|
/** every room by id, for naming the public ones (Resource.showOnEvents) */
|
|
93
108
|
export const roomMap = async (repo: ContentRepository) => new Map((await repo.listResources()).map((r) => [r.id, r]));
|
|
94
|
-
// 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
|
|
95
110
|
export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>) {
|
|
96
|
-
|
|
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,
|
|
97
115
|
rooms: roomsToJson(o.roomIds, resources) };
|
|
98
116
|
}
|
|
99
117
|
// e is already localized; publicCalendars limits which calendars are named
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -43,11 +43,13 @@ export async function limitRequest(repo: ContentRepository, address: string, wri
|
|
|
43
43
|
const key = `mobile:${write ? 'write' : 'read'}:${await sha256Hex(address)}`;
|
|
44
44
|
if (!await repo.email.rateLimit(key, write ? 20 : 300, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
// lists: parameters that may repeat (tag=a&tag=b); their values are joined
|
|
47
|
+
// with commas, the same as tag=a,b
|
|
48
|
+
export function queryParams(url: URL, lists: readonly string[] = []) {
|
|
47
49
|
const result: Record<string, string> = {};
|
|
48
50
|
for (const [key, value] of url.searchParams) {
|
|
49
|
-
if (key in result) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
|
|
50
|
-
result[key] = value;
|
|
51
|
+
if (key in result && !lists.includes(key)) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
|
|
52
|
+
result[key] = key in result ? `${result[key]},${value}` : value;
|
|
51
53
|
}
|
|
52
54
|
return result;
|
|
53
55
|
}
|
|
@@ -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();
|
package/src/lib/push/service.ts
CHANGED
|
@@ -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
|
|
400
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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));
|
package/src/lib/push/types.ts
CHANGED
|
@@ -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
|
};
|