@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.
- package/CHANGELOG.md +56 -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/integration.mjs +37 -0
- 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 +5 -5
- package/src/lib/calendar/change-migration.ts +40 -15
- package/src/lib/calendar/data.ts +9 -3
- package/src/lib/calendar/event-words.ts +9 -0
- package/src/lib/calendar/events.ts +94 -9
- package/src/lib/calendar/ics-write.ts +25 -2
- package/src/lib/calendar/moves.ts +291 -0
- package/src/lib/calendar/render.ts +10 -6
- 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 +5 -2
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/origin-check.ts +54 -0
- 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/middleware.ts +5 -0
- package/src/pages/api/calendar/events/index.ts +6 -0
- 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 +6 -0
- package/src/theme-sdk/calendar.ts +3 -2
- package/src/virtual.d.ts +2 -0
|
@@ -12,11 +12,15 @@ export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|
|
|
|
12
12
|
return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
|
|
13
13
|
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
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];
|
|
@@ -106,9 +106,12 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
|
|
|
106
106
|
}
|
|
107
107
|
/** every room by id, for naming the public ones (Resource.showOnEvents) */
|
|
108
108
|
export const roomMap = async (repo: ContentRepository) => new Map((await repo.listResources()).map((r) => [r.id, r]));
|
|
109
|
-
// one date: its time, whether it's cancelled, and the public rooms it holds
|
|
109
|
+
// one date: its time, whether it's cancelled, where it moved from, and the public rooms it holds
|
|
110
110
|
export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>) {
|
|
111
|
-
|
|
111
|
+
const iso = (n: number) => new Date(n).toISOString();
|
|
112
|
+
return { id: `${o.event.id}:${o.start}`, start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled,
|
|
113
|
+
moved: o.moved ? { from: { start: iso(o.moved.from.start), end: iso(o.moved.from.end) }, note: o.moved.note,
|
|
114
|
+
location: o.moved.location ? { name: o.moved.location.name, address: o.moved.location.address, url: o.moved.location.url } : null } : null,
|
|
112
115
|
rooms: roomsToJson(o.roomIds, resources) };
|
|
113
116
|
}
|
|
114
117
|
// e is already localized; publicCalendars limits which calendars are named
|
|
@@ -29,24 +29,28 @@ export const Page = ContentSummary.extend({
|
|
|
29
29
|
});
|
|
30
30
|
// a room an event (or one date) holds, named only when set to show on public events; color null = none
|
|
31
31
|
export const EventRoom = z.object({ slug: str, name: str, color: str.nullable() });
|
|
32
|
+
// one date moved here from `from` (null when it wasn't); location: its own place, null = the event's
|
|
33
|
+
export const OccurrenceMoved = z.object({ from: z.object({ start: date, end: date }), note: str, location: z.object({ name: str, address: str, url: str }).nullable() }).nullable();
|
|
32
34
|
export const Event = CalEvent.pick({
|
|
33
35
|
id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
|
|
34
36
|
allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
|
|
35
37
|
}).extend({ rsvp: CalEvent.shape.rsvp.removeDefault().omit({ notifyStaff: true }).default({}).optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
|
|
36
38
|
calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
|
|
37
39
|
rooms: z.array(EventRoom),
|
|
40
|
+
// single dates at another time, by original occurrence key (wall-clock, like start/end)
|
|
41
|
+
movedDates: z.record(z.object({ start: str, end: str, note: str })),
|
|
38
42
|
// push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
|
|
39
43
|
notify: z.object({ changes: strings }),
|
|
40
44
|
});
|
|
41
45
|
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
42
|
-
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom), event: Event })),
|
|
46
|
+
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom), event: Event })),
|
|
43
47
|
});
|
|
44
48
|
const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
|
|
45
49
|
const ContentHit = ContentSummary.extend({ snippet: Snippet });
|
|
46
|
-
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom) }).nullable(), event: Event });
|
|
50
|
+
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom) }).nullable(), event: Event });
|
|
47
51
|
export const SearchResults = z.object({ locale, query: str,
|
|
48
52
|
posts: z.array(ContentHit), pages: z.array(ContentHit),
|
|
49
|
-
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom) }).nullable(), event: Event })),
|
|
53
|
+
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom) }).nullable(), event: Event })),
|
|
50
54
|
more: z.object({ posts: bool, pages: bool, events: bool }),
|
|
51
55
|
});
|
|
52
56
|
const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
|
|
3
|
+
// Astro's CSRF origin check (security.checkOrigin), taken over by core.
|
|
4
|
+
// Astro's own copy runs before any middleware and no route can opt out of it,
|
|
5
|
+
// so requests that never come from a browser on another site were refused
|
|
6
|
+
// too: Amazon SNS posts SES bounces as text/plain with no Origin header, and
|
|
7
|
+
// the apps send none either. integration.mjs turns Astro's copy off and the
|
|
8
|
+
// middleware runs this one first instead, in dev as well as on built sites.
|
|
9
|
+
//
|
|
10
|
+
// The rule is Astro 5's for anything a page on another site can send: a POST
|
|
11
|
+
// whose Origin isn't this site's (or that has none) is refused when its body
|
|
12
|
+
// is form-like (form-urlencoded, multipart, text/plain) or it has no
|
|
13
|
+
// Content-Type. JSON and other types need a CORS preflight and pass. What
|
|
14
|
+
// passes besides: the routes below, and requests a browser only sends to
|
|
15
|
+
// another site after a preflight (preflighted()).
|
|
16
|
+
|
|
17
|
+
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
18
|
+
const FORM_TYPES = ['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'];
|
|
19
|
+
|
|
20
|
+
// Routes whose senders are servers that send no Origin and prove who they are
|
|
21
|
+
// with a signature the route checks itself (lib/email/webhooks.ts), keyed by
|
|
22
|
+
// route pattern as Astro matched it, so a lookalike path never qualifies. A
|
|
23
|
+
// site adds its own with acms({ signedRoutes }).
|
|
24
|
+
export const SIGNED_ROUTES = new Set([
|
|
25
|
+
'/api/email/webhooks/[provider]',
|
|
26
|
+
...(userConfig.originCheck?.signedRoutes ?? []),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// The editor apps' OAuth endpoints (RFC 6749): form posts that carry their
|
|
30
|
+
// own code, refresh token or token to revoke, and never use cookies.
|
|
31
|
+
export const TOKEN_ROUTES = new Set(['/api/v1/auth/token', '/api/v1/auth/revoke']);
|
|
32
|
+
|
|
33
|
+
// A request no browser sends to another site without asking it first (a CORS
|
|
34
|
+
// preflight; ACMS never allows credentials in one, so no cookie rides along):
|
|
35
|
+
// any method but POST, or one carrying an app's own credential, a bearer
|
|
36
|
+
// token or a booking token. Basic auth doesn't count: a browser adds that to
|
|
37
|
+
// a form post by itself.
|
|
38
|
+
export function preflighted(request: Request): boolean {
|
|
39
|
+
return request.method !== 'POST' || /^bearer\s/i.test(request.headers.get('authorization') ?? '') || request.headers.has('booking-token');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// a site that set security.checkOrigin: false itself keeps the check off
|
|
43
|
+
export const ORIGIN_CHECK = { enabled: userConfig.originCheck?.enabled !== false };
|
|
44
|
+
|
|
45
|
+
type CheckedContext = { request: Request; url: URL; routePattern: string; isPrerendered: boolean };
|
|
46
|
+
|
|
47
|
+
/** Astro's 403 for a cross-site form submission, or null when the request may go on. */
|
|
48
|
+
export function crossSiteRefusal({ request, url, routePattern, isPrerendered }: CheckedContext): Response | null {
|
|
49
|
+
if (!ORIGIN_CHECK.enabled || isPrerendered || SAFE_METHODS.includes(request.method)) return null;
|
|
50
|
+
if (request.headers.get('origin') === url.origin || SIGNED_ROUTES.has(routePattern) || TOKEN_ROUTES.has(routePattern) || preflighted(request)) return null;
|
|
51
|
+
const type = request.headers.get('content-type');
|
|
52
|
+
if (type !== null && !FORM_TYPES.some((form) => type.toLowerCase().includes(form))) return null;
|
|
53
|
+
return new Response(`Cross-site ${request.method} form submissions are forbidden`, { status: 403 });
|
|
54
|
+
}
|
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
|
};
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { LiveConfig } from '../live/types';
|
|
|
13
13
|
import { ApiError } from '../api';
|
|
14
14
|
import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
|
|
15
15
|
import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
|
|
16
|
-
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
16
|
+
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, MovedDate, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
17
17
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
18
18
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
19
19
|
import { cleanRrule } from '../calendar/rrule';
|
|
@@ -148,6 +148,15 @@ function parseRequest(raw: unknown): EventRequest | null {
|
|
|
148
148
|
if (raw === null || raw === undefined || raw === '') return null;
|
|
149
149
|
try { const r = EventRequest.safeParse(JSON.parse(String(raw))); return r.success ? r.data : null; } catch { return null; }
|
|
150
150
|
}
|
|
151
|
+
// a corrupt moved-dates map, or one bad entry, reads as "not moved" rather than breaking the event
|
|
152
|
+
function parseMovedDates(raw: unknown): CalEvent['movedDates'] {
|
|
153
|
+
let value: unknown;
|
|
154
|
+
try { value = JSON.parse(String(raw ?? '{}')); } catch { return {}; }
|
|
155
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
156
|
+
const out: CalEvent['movedDates'] = {};
|
|
157
|
+
for (const [key, entry] of Object.entries(value)) { const m = MovedDate.safeParse(entry); if (m.success) out[key] = m.data; }
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
151
160
|
function rowToEvent(r: Row): CalEvent {
|
|
152
161
|
return CalEvent.parse({
|
|
153
162
|
id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
|
|
@@ -155,7 +164,7 @@ function rowToEvent(r: Row): CalEvent {
|
|
|
155
164
|
seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
|
|
156
165
|
featured: Boolean(r.featured), tags: json(r.tags, []), template: r.template ?? '',
|
|
157
166
|
start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
|
|
158
|
-
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []),
|
|
167
|
+
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []), movedDates: parseMovedDates(r.moved_dates),
|
|
159
168
|
location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
|
|
160
169
|
fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
|
|
161
170
|
hostIds: json(r.host_ids, []), request: parseRequest(r.request),
|
|
@@ -848,8 +857,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
848
857
|
await this.driver.run(
|
|
849
858
|
`INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
850
859
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
851
|
-
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations)
|
|
852
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
860
|
+
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations, moved_dates)
|
|
861
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
853
862
|
[
|
|
854
863
|
id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
|
|
855
864
|
data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
|
|
@@ -859,6 +868,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
859
868
|
data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
|
|
860
869
|
JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
|
|
861
870
|
JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null, JSON.stringify(data.translations ?? {}),
|
|
871
|
+
JSON.stringify(data.movedDates ?? {}),
|
|
862
872
|
]
|
|
863
873
|
);
|
|
864
874
|
await this.syncEventIndex(id);
|
|
@@ -883,7 +893,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
883
893
|
if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
|
|
884
894
|
}
|
|
885
895
|
const jsonCols: Record<string, string> = {
|
|
886
|
-
tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
|
|
896
|
+
tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', movedDates: 'moved_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
|
|
887
897
|
};
|
|
888
898
|
for (const [k, col] of Object.entries(jsonCols)) {
|
|
889
899
|
const v = (data as Record<string, unknown>)[k];
|
|
@@ -955,6 +965,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
955
965
|
await this.ready;
|
|
956
966
|
await this.driver.run('UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', [JSON.stringify(dates), Date.now(), id]);
|
|
957
967
|
}
|
|
968
|
+
async setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts: { quiet?: boolean } = {}) {
|
|
969
|
+
await this.ready;
|
|
970
|
+
const write = cancelledDates
|
|
971
|
+
? { sql: 'UPDATE events SET moved_dates = ?, cancelled_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), JSON.stringify(cancelledDates), Date.now(), id] }
|
|
972
|
+
: { sql: 'UPDATE events SET moved_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), Date.now(), id] };
|
|
973
|
+
// quiet: the change it records sends its webhook but no member push (the flag shares this transaction)
|
|
974
|
+
await this.driver.batch(opts.quiet ? [{ sql: 'UPDATE push_control SET quiet=1 WHERE id=1' }, write, { sql: 'UPDATE push_control SET quiet=0 WHERE id=1' }] : [write]);
|
|
975
|
+
}
|
|
958
976
|
async cancelOccurrenceRsvps(id: string, start: number) {
|
|
959
977
|
await this.ready;
|
|
960
978
|
// the mutation id limits the "cancelled" emails to exactly these RSVPs (as deleteEvent does)
|
|
@@ -966,12 +984,12 @@ export class SqlRepository implements ContentRepository {
|
|
|
966
984
|
]);
|
|
967
985
|
return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
|
|
968
986
|
}
|
|
969
|
-
async moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }) {
|
|
987
|
+
async moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }, message = '') {
|
|
970
988
|
await this.ready;
|
|
971
989
|
const target = to.eventId || id;
|
|
972
990
|
// one mutation id marks every moved row; only the seated ones are emailed
|
|
973
991
|
const mutation = crypto.randomUUID();
|
|
974
|
-
const notifications = bookingJobs(target,'rescheduled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')")}));
|
|
992
|
+
const notifications = bookingJobs(target,'rescheduled',undefined,false,mutation,message).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')")}));
|
|
975
993
|
await this.driver.batch([
|
|
976
994
|
{sql:`UPDATE bookings SET event_id=?,start_at=?,end_at=?,hold_start=?,hold_end=?,notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=?
|
|
977
995
|
WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ${RSVP_ACTIVE}`,args:[target,to.start,to.end,to.start,to.end,mutation,Date.now(),id,start]},
|
|
@@ -1906,15 +1924,15 @@ export class SqlRepository implements ContentRepository {
|
|
|
1906
1924
|
...(content.events ?? []).map((e) => ({
|
|
1907
1925
|
sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
1908
1926
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
1909
|
-
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request)
|
|
1910
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1927
|
+
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, moved_dates)
|
|
1928
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1911
1929
|
args: [
|
|
1912
1930
|
e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
|
|
1913
1931
|
JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
|
|
1914
1932
|
e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
|
|
1915
1933
|
e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
|
|
1916
1934
|
JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}), JSON.stringify(e.cancelledDates ?? []),
|
|
1917
|
-
JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null,
|
|
1935
|
+
JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null, JSON.stringify(e.movedDates ?? {}),
|
|
1918
1936
|
],
|
|
1919
1937
|
})),
|
|
1920
1938
|
// announcements (a backup made before them leaves the current ones). Restored
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -48,7 +48,7 @@ export type EventUpdate = Partial<
|
|
|
48
48
|
CalEvent,
|
|
49
49
|
| 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
|
|
50
50
|
| 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
|
|
51
|
-
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates'
|
|
51
|
+
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates' | 'movedDates'
|
|
52
52
|
| 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
|
|
53
53
|
| 'publishedAt' | 'scheduledAt'
|
|
54
54
|
>
|
|
@@ -168,15 +168,18 @@ export interface ContentRepository {
|
|
|
168
168
|
discardEventDraft(id: string): Promise<void>;
|
|
169
169
|
/** replace an event's cancelled occurrence keys at once, keeping any staged draft */
|
|
170
170
|
setEventCancelledDates(id: string, dates: string[]): Promise<void>;
|
|
171
|
+
/** replace an event's moved dates (and cancelled dates, when given) at once, keeping any staged draft; quiet: no member push for it */
|
|
172
|
+
setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts?: { quiet?: boolean }): Promise<void>;
|
|
171
173
|
/** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
|
|
172
174
|
cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
|
|
173
175
|
/**
|
|
174
176
|
* Moves the RSVPs for one date of an event (those starting at `start`) to a
|
|
175
177
|
* new time, and to another event when `to.eventId` is set. Pending and
|
|
176
|
-
* confirmed ones get the "new time" email
|
|
178
|
+
* confirmed ones get the "new time" email (with `message`, when given);
|
|
179
|
+
* places in line move quietly.
|
|
177
180
|
* Returns how many moved.
|
|
178
181
|
*/
|
|
179
|
-
moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }): Promise<number>;
|
|
182
|
+
moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }, message?: string): Promise<number>;
|
|
180
183
|
deleteEvent(id: string): Promise<void>;
|
|
181
184
|
// saved-place sync (lib/maps/places.ts): rewrite just the location of the
|
|
182
185
|
// live row and/or the staged draft, leaving every other field — and the
|
package/src/lib/schema.ts
CHANGED
|
@@ -357,6 +357,24 @@ export const RoomHold = z.object({
|
|
|
357
357
|
});
|
|
358
358
|
export type RoomHold = z.infer<typeof RoomHold>;
|
|
359
359
|
|
|
360
|
+
// One date of a repeating event at another time (and maybe in other rooms or
|
|
361
|
+
// another place): CalEvent.movedDates, keyed by the date's ORIGINAL occurrence
|
|
362
|
+
// key. start/end are wall-clock in the event's zone like the event's own
|
|
363
|
+
// (dates for all-day events, end inclusive). Unset roomIds/location = the
|
|
364
|
+
// event's. note is public ("Moved for the youth retreat"); by is who moved it
|
|
365
|
+
// (a user id, or 'google-sync'), at when. lib/calendar/moves.ts writes these.
|
|
366
|
+
export const MovedDate = z.object({
|
|
367
|
+
start: z.string(),
|
|
368
|
+
end: z.string(),
|
|
369
|
+
roomIds: z.array(z.string()).optional(),
|
|
370
|
+
location: EventLocation.optional(),
|
|
371
|
+
note: z.string().max(500).default(''),
|
|
372
|
+
by: z.string().default(''),
|
|
373
|
+
at: z.number().default(0),
|
|
374
|
+
});
|
|
375
|
+
export type MovedDate = z.infer<typeof MovedDate>;
|
|
376
|
+
export const MovedDates = z.record(z.string(), MovedDate).default({});
|
|
377
|
+
|
|
360
378
|
// A member's event request (lib/calendar/requests.ts): a new event waiting
|
|
361
379
|
// for approval (kind 'new', the event stays a draft), or changes to a live
|
|
362
380
|
// event staged in its `draft` (kind 'changes'). Staff approve or decline;
|
|
@@ -407,6 +425,9 @@ export const CalEvent = z.object({
|
|
|
407
425
|
// cancelled: shown struck through, but they don't hold rooms. Keys that no
|
|
408
426
|
// longer match an occurrence (the series moved) are ignored.
|
|
409
427
|
cancelledDates: z.array(z.string()).default([]),
|
|
428
|
+
// single dates at another time (MovedDate above); a date is moved or
|
|
429
|
+
// cancelled, never both. Keys that no longer match an occurrence are ignored.
|
|
430
|
+
movedDates: MovedDates,
|
|
410
431
|
location: EventLocation.default({}),
|
|
411
432
|
cta: EventCta.default({}),
|
|
412
433
|
calendarIds: z.array(z.string()).default([]),
|
package/src/lib/sections.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface LinkValue { label: string; href: string; external: boolean }
|
|
|
32
32
|
export interface EventItem {
|
|
33
33
|
title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
|
|
34
34
|
start: string; end: string; allDay: boolean; cancelled: boolean;
|
|
35
|
+
/** this date was moved here: from "Thu, Oct 1 · 6:00 PM" (where it was), with staff's public note; null when not moved */
|
|
36
|
+
moved: { from: string; note: string } | null;
|
|
35
37
|
/** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
|
|
36
38
|
/** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
|
|
37
39
|
cta: { label: string; href: string } | null;
|
|
@@ -141,11 +143,12 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
141
143
|
return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag })).map((o): EventItem => {
|
|
142
144
|
const e = lang ? localizeDoc(o.event, lang) : o.event;
|
|
143
145
|
const f = formatOccurrence(o, tz, locale);
|
|
146
|
+
const was = o.moved ? formatOccurrence({ ...o, start: o.moved.from.start, end: o.moved.from.end }, tz, locale) : null;
|
|
144
147
|
const ctaHref = safeUrl(e.cta.href);
|
|
145
148
|
return {
|
|
146
149
|
title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
|
|
147
|
-
location: locationLine(e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
148
|
-
allDay: o.allDay, cancelled: o.cancelled, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
|
|
150
|
+
location: locationLine(o.moved?.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
151
|
+
allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
|
|
149
152
|
cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
|
|
150
153
|
};
|
|
151
154
|
});
|
package/src/lib/sync/catalog.ts
CHANGED
package/src/middleware.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { processPushQueue, runPushJobs } from './lib/push/service';
|
|
|
15
15
|
import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
|
|
16
16
|
import { applyCachePolicy } from './lib/cache/policy';
|
|
17
17
|
import { bindExecutionContext, syncCachePurge } from './lib/cache/purge';
|
|
18
|
+
import { crossSiteRefusal } from './lib/origin-check';
|
|
18
19
|
|
|
19
20
|
// Legacy content-URL redirects, checked only on would-be 404s (below) so they
|
|
20
21
|
// can never shadow real content. Tags moved from `${base}/tag/` to a
|
|
@@ -81,6 +82,10 @@ export const onRequest = defineMiddleware(async (context, next) =>
|
|
|
81
82
|
|
|
82
83
|
async function handle(context: APIContext, next: MiddlewareNext): Promise<Response> {
|
|
83
84
|
const { pathname } = context.url;
|
|
85
|
+
// cross-site form submissions, before anything else runs (Astro's own
|
|
86
|
+
// check, which signed webhooks and the apps couldn't pass: lib/origin-check.ts)
|
|
87
|
+
const refused = crossSiteRefusal(context);
|
|
88
|
+
if (refused) return refused;
|
|
84
89
|
bindAuthEnvironment(context.request, context.locals);
|
|
85
90
|
bindExecutionContext(context.request, context.locals);
|
|
86
91
|
|
|
@@ -100,6 +100,12 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
|
100
100
|
end: o.allDay ? isoWithOffset(o.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.end, etz),
|
|
101
101
|
allDay: o.allDay,
|
|
102
102
|
cancelled: o.cancelled,
|
|
103
|
+
// the date's key (a moved date keeps its original one), and where it moved from
|
|
104
|
+
key: o.key,
|
|
105
|
+
moved: o.moved ? {
|
|
106
|
+
from: { start: o.allDay ? isoWithOffset(o.moved.from.start, etz).slice(0, 10) : isoWithOffset(o.moved.from.start, etz), end: o.allDay ? isoWithOffset(o.moved.from.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.moved.from.end, etz) },
|
|
107
|
+
note: o.moved.note, location: o.moved.location ?? null,
|
|
108
|
+
} : null,
|
|
103
109
|
// that date's rooms
|
|
104
110
|
rooms: roomsToJson(o.roomIds, resources, full),
|
|
105
111
|
event: toJson(o.event.id),
|
|
@@ -62,6 +62,7 @@ const { title, user } = Astro.props;
|
|
|
62
62
|
.pc-mem-dates li { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 42px; padding: 5px 0; border-top: 1px solid var(--line); font-variant-numeric: tabular-nums; }
|
|
63
63
|
.pc-mem-dates li.is-cancelled > span { color: var(--muted); }
|
|
64
64
|
.pc-mem-dates em { font-style: normal; font-size: 12.5px; font-weight: 600; color: var(--danger); margin-left: 8px; }
|
|
65
|
+
.pc-mem-dates em.is-moved { color: var(--accent); font-weight: 500; }
|
|
65
66
|
.pc-mem-past > summary { cursor: pointer; font-weight: 600; font-size: 15px; }
|
|
66
67
|
.pc-mem-past .pc-mem-list { margin-top: 10px; }
|
|
67
68
|
/* the request form */
|
|
@@ -175,7 +175,7 @@ const pastShown = past.slice(0, 10);
|
|
|
175
175
|
const off = e.cancelledDates.includes(o.key);
|
|
176
176
|
return (
|
|
177
177
|
<li class={o.cancelled ? 'is-cancelled' : undefined}>
|
|
178
|
-
<span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}</span>
|
|
178
|
+
<span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}{o.moved && !o.cancelled && <em class="is-moved" title={o.moved.note || undefined}>Moved from {when({ ...o, start: o.moved.from.start, end: o.moved.from.end })}</em>}</span>
|
|
179
179
|
{e.rrule && !e.cancelled && o.start > now && (
|
|
180
180
|
<form method="post" onsubmit={off ? undefined : "return confirm('Cancel this date? It shows as cancelled straight away.')"}>
|
|
181
181
|
<input type="hidden" name="op" value={off ? 'restore-date' : 'cancel-date'} />
|
package/src/server/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export interface ServerUser {
|
|
|
37
37
|
export type { CalEvent, Occurrence };
|
|
38
38
|
export type { EventRequestInput } from '../lib/calendar/requests';
|
|
39
39
|
export type { EventClash, BusySlot, Hold } from '../lib/booking/holds';
|
|
40
|
+
export type { MoveInput, MoveResult } from '../lib/calendar/moves';
|
|
40
41
|
|
|
41
42
|
export interface ListEventsOptions {
|
|
42
43
|
/** only published events (default: every event except archived ones) */
|
|
@@ -172,6 +173,38 @@ export async function restoreOccurrence(repo: Repo, eventId: string, occurrenceK
|
|
|
172
173
|
return restore(internal(repo), eventId, occurrenceKey, { url: context.url, locals: context.locals });
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
// ---- moved dates -------------------------------------------------------------
|
|
177
|
+
// One date of a repeating event at another time (and maybe other rooms or
|
|
178
|
+
// another place), keeping its key, its RSVPs (moved with it; each person gets
|
|
179
|
+
// the "new time" email with their cancel link) and its hosts. These check who
|
|
180
|
+
// is acting: staff ("Create and edit content") and GOOGLE_SYNC may move any
|
|
181
|
+
// date, and a room taken at the new time comes back in `clashes` as a
|
|
182
|
+
// warning; a host of the event may move its dates only while the rooms are
|
|
183
|
+
// free (409 otherwise), and staff are emailed about it.
|
|
184
|
+
|
|
185
|
+
/** The actor for a calendar sync acting with staff rights (a change already made elsewhere): clashes only warn. */
|
|
186
|
+
export const GOOGLE_SYNC = 'google-sync';
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Move one date (by its occurrence key) to `input.start` (and `end`, else it
|
|
190
|
+
* keeps its length; wall-clock in the event's timezone, like `start`).
|
|
191
|
+
* `roomIds`/`location`/`note` replace that date's own (omit to keep them).
|
|
192
|
+
* Applies at once, even on a published event. `quiet` skips the app notice
|
|
193
|
+
* and the staff email (a sync catching up); RSVP emails still go. Errors have
|
|
194
|
+
* a `status`: 400 (not a repeating event, not one of its dates, started or a
|
|
195
|
+
* past time), 401, 404, 409 (a cancelled date, or a taken room for a host).
|
|
196
|
+
*/
|
|
197
|
+
export async function moveOccurrence(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/moves').MoveResult> {
|
|
198
|
+
const { moveOccurrence: move } = await import('../lib/calendar/moves');
|
|
199
|
+
return move(internal(repo), who, eventId, occurrenceKey, input, { url: context.url, locals: context.locals }, opts);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Put a moved date back where the series has it. Same rules; idempotent. */
|
|
203
|
+
export async function moveOccurrenceBack(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/moves').MoveResult> {
|
|
204
|
+
const { moveOccurrenceBack: back } = await import('../lib/calendar/moves');
|
|
205
|
+
return back(internal(repo), who, eventId, occurrenceKey, { url: context.url, locals: context.locals }, opts);
|
|
206
|
+
}
|
|
207
|
+
|
|
175
208
|
// ---- rooms -----------------------------------------------------------------
|
|
176
209
|
// Events hold rooms (CalEvent.roomHold) while they happen. These find where
|
|
177
210
|
// two things want the same room. They don't check permissions, and their
|