@acms-subir/core 0.3.19 → 0.3.20
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 +14 -0
- package/cli/main.mjs +3 -2
- package/cli/migrations.generated.mjs +12 -1
- package/docs/server.md +144 -0
- package/integration.mjs +8 -3
- package/package.json +2 -1
- package/src/actions/index.ts +13 -1
- package/src/components/EventPage.astro +7 -4
- package/src/components/calendar/CalendarBoard.tsx +11 -2
- package/src/components/editor/event/OrganizeCard.tsx +1 -1
- package/src/lib/booking/holds.ts +4 -2
- package/src/lib/booking/service.ts +4 -1
- package/src/lib/calendar/agent.ts +1 -1
- package/src/lib/calendar/api.ts +8 -5
- package/src/lib/calendar/board.ts +6 -7
- package/src/lib/calendar/cards.ts +8 -6
- package/src/lib/calendar/config.ts +12 -0
- package/src/lib/calendar/data.ts +10 -3
- package/src/lib/calendar/events.ts +35 -2
- package/src/lib/calendar/ics-write.ts +27 -3
- package/src/lib/calendar/schedule.ts +45 -4
- package/src/lib/calendar/seo.ts +2 -2
- package/src/lib/content-service.ts +4 -2
- package/src/lib/mobile/calendar.ts +4 -3
- package/src/lib/mobile/schemas.ts +3 -3
- package/src/lib/mobile/search.ts +1 -1
- package/src/lib/repo/migrations.ts +12 -1
- package/src/lib/repo/sql.ts +13 -8
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/schema.ts +4 -0
- package/src/lib/sections.ts +2 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/api/calendar/docs.ts +1 -1
- package/src/pages/api/calendar/events/[id].ts +1 -1
- package/src/pages/api/calendar/events/index.ts +6 -4
- package/src/pages/calendar/index.astro +1 -1
- package/src/pages/events/[slug].astro +4 -3
- package/src/server/index.ts +141 -0
- package/src/theme-sdk/calendar.ts +6 -2
- package/src/virtual.d.ts +1 -1
package/src/lib/calendar/data.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { ContentRepository } from '../repo/types';
|
|
|
6
6
|
import type { CalEvent, Calendar, Settings } from '../schema';
|
|
7
7
|
import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
|
|
8
8
|
import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
|
|
9
|
-
import { CALENDAR, eventUrl, resourceUrl } from './config';
|
|
9
|
+
import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
|
|
10
10
|
|
|
11
11
|
export interface ViewEvent extends CalendarEvent {
|
|
12
12
|
url?: string;
|
|
@@ -63,6 +63,13 @@ export async function collectViewEvents(
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
const events: ViewEvent[] = [];
|
|
66
|
+
// public views (cards, sections, theme helpers) never carry private fields
|
|
67
|
+
const shown = new Map<string, CalEvent>();
|
|
68
|
+
const view = (e: CalEvent) => {
|
|
69
|
+
if (scope.audience === 'admin') return e;
|
|
70
|
+
if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(e));
|
|
71
|
+
return shown.get(e.id)!;
|
|
72
|
+
};
|
|
66
73
|
for (const o of expandEvents(natives, settings, win)) {
|
|
67
74
|
const e = o.event;
|
|
68
75
|
const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
|
|
@@ -77,11 +84,11 @@ export async function collectViewEvents(
|
|
|
77
84
|
ref: e.id,
|
|
78
85
|
url: eventUrl(e.slug) + on,
|
|
79
86
|
color: cal ? safeColor(cal.color) : undefined,
|
|
80
|
-
cancelled:
|
|
87
|
+
cancelled: o.cancelled,
|
|
81
88
|
image: e.cover || undefined,
|
|
82
89
|
imageAlt: e.coverAlt || undefined,
|
|
83
90
|
calendarName: cal?.name,
|
|
84
|
-
event: e,
|
|
91
|
+
event: view(e),
|
|
85
92
|
});
|
|
86
93
|
}
|
|
87
94
|
|
|
@@ -116,6 +116,25 @@ export interface Occurrence {
|
|
|
116
116
|
end: number;
|
|
117
117
|
allDay: boolean;
|
|
118
118
|
days: number;
|
|
119
|
+
// this occurrence's start as wall time ('YYYY-MM-DDTHH:mm' in the event's
|
|
120
|
+
// zone, 'YYYY-MM-DD' for all-day): what exdates and cancelledDates store
|
|
121
|
+
key: string;
|
|
122
|
+
// the whole event is cancelled, or just this date
|
|
123
|
+
cancelled: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The occurrence key for an occurrence start. Timed events use the event's
|
|
128
|
+
* own zone; all-day occurrences start at midnight in the display zone (how
|
|
129
|
+
* the expander places them), so their date is read there.
|
|
130
|
+
*/
|
|
131
|
+
export function occurrenceKey(e: Pick<CalEvent, 'allDay' | 'timezone'>, start: number, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): string {
|
|
132
|
+
if (e.allDay) {
|
|
133
|
+
const p = tzParts(start, displayTz || 'UTC');
|
|
134
|
+
return `${p.y}-${pad(p.m)}-${pad(p.d)}`;
|
|
135
|
+
}
|
|
136
|
+
const p = tzParts(start, eventTimeZone(e, settings));
|
|
137
|
+
return `${p.y}-${pad(p.m)}-${pad(p.d)}T${pad(p.hh)}:${pad(p.mm)}`;
|
|
119
138
|
}
|
|
120
139
|
|
|
121
140
|
export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timezone'>, win: CalendarWindow): Occurrence[] {
|
|
@@ -124,13 +143,26 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
|
|
|
124
143
|
const tz = eventTimeZone(e, settings);
|
|
125
144
|
const v = toVEvent(e, tz);
|
|
126
145
|
if (!v) continue;
|
|
146
|
+
// ?? []: editor previews pass unsaved events that may lack the field
|
|
147
|
+
const cancelledDates = new Set(e.cancelledDates ?? []);
|
|
127
148
|
for (const o of expandVEvents([v], tz, win, 0)) {
|
|
128
|
-
|
|
149
|
+
const key = occurrenceKey(e, o.start, settings, win.displayTz);
|
|
150
|
+
out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key) });
|
|
129
151
|
}
|
|
130
152
|
}
|
|
131
153
|
return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
|
|
132
154
|
}
|
|
133
155
|
|
|
156
|
+
// The occurrence of a series with this key (null when the series has no
|
|
157
|
+
// such date: never generated, skipped by exdates, or the series moved)
|
|
158
|
+
export function findOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): Occurrence | null {
|
|
159
|
+
const w = parseWall(key);
|
|
160
|
+
if (!w || w.dateOnly !== e.allDay) return null;
|
|
161
|
+
const at = e.allDay ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz || 'UTC') : wallToMs(w, eventTimeZone(e, settings));
|
|
162
|
+
return expandEvents([e], settings, { displayTz, now: at, windowStartMs: at - DAY, windowEndMs: at + DAY })
|
|
163
|
+
.find((o) => o.key === key && o.start === at) ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
// The occurrence to feature for an event: the next one that hasn't ended,
|
|
135
167
|
// else the most recent. `on` ('YYYY-MM-DD') picks a specific day's instance
|
|
136
168
|
// (links from the calendar grid carry it).
|
|
@@ -151,7 +183,8 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
|
|
|
151
183
|
const s = parseWall(e.start);
|
|
152
184
|
const en = parseWall(e.end) ?? s;
|
|
153
185
|
const days = e.allDay && s && en ? Math.round((wallMinutes(en) - wallMinutes(s)) / 1440) + 1 : 1;
|
|
154
|
-
|
|
186
|
+
const key = occurrenceKey(e, startAt, settings, displayTz);
|
|
187
|
+
return { event: e, start: startAt, end: endAt, allDay: e.allDay, days, key, cancelled: e.cancelled || (e.cancelledDates ?? []).includes(key) };
|
|
155
188
|
}
|
|
156
189
|
const next = expandEvents([e], settings, { displayTz, now, windowStartMs: now, windowEndMs: now + 3 * 366 * DAY })[0];
|
|
157
190
|
if (next) return next;
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// events are written in UTC (unambiguous everywhere); recurring timed events
|
|
4
4
|
// keep their wall clock with TZID=<IANA zone> so a weekly 10am stays at 10am
|
|
5
5
|
// across DST — Google, Apple and Outlook all resolve IANA zone ids. All-day
|
|
6
|
-
// events are VALUE=DATE with the RFC's exclusive DTEND.
|
|
6
|
+
// events are VALUE=DATE with the RFC's exclusive DTEND. A single cancelled
|
|
7
|
+
// date of a series is an override VEVENT (same UID, RECURRENCE-ID) with
|
|
8
|
+
// STATUS:CANCELLED, so calendar apps strike that one date.
|
|
7
9
|
import type { CalEvent, Calendar } from '../schema';
|
|
8
|
-
import { addWallDays, eventInstants, eventTimeZone, locationLine, parseWall, type Wall } from './events';
|
|
10
|
+
import { addWallDays, eventInstants, eventTimeZone, findOccurrence, locationLine, parseWall, type Wall } from './events';
|
|
9
11
|
import { zonedToUtc } from './ics';
|
|
10
12
|
import { cleanRrule, rruleMap } from './rrule';
|
|
11
13
|
|
|
@@ -99,10 +101,32 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
|
|
|
99
101
|
const cats = e.calendarIds.map((id) => opts.calendars?.get(id)?.name).filter((n): n is string => Boolean(n));
|
|
100
102
|
if (cats.length) lines.push(`CATEGORIES:${cats.map(escapeText).join(',')}`);
|
|
101
103
|
if (e.cancelled) lines.push('STATUS:CANCELLED');
|
|
102
|
-
|
|
104
|
+
const stamps = [`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`];
|
|
105
|
+
lines.push(...stamps, 'END:VEVENT');
|
|
106
|
+
if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps));
|
|
103
107
|
return lines;
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
// one override per cancelled date that is still part of the series (keys
|
|
111
|
+
// left behind by a moved series are ignored)
|
|
112
|
+
function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
|
|
113
|
+
const out: string[] = [];
|
|
114
|
+
const toMin = (w: Wall) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm) / 60_000;
|
|
115
|
+
const lenMin = e.allDay ? (toMin({ ...en, hh: 0, mm: 0 }) - toMin({ ...s, hh: 0, mm: 0 })) + 1440 : Math.max(0, toMin(en) - toMin(s));
|
|
116
|
+
for (const key of [...new Set(e.cancelledDates ?? [])].sort()) {
|
|
117
|
+
const w = parseWall(key);
|
|
118
|
+
if (!w || !findOccurrence(e, key, { timezone: siteTz }, siteTz || 'UTC')) continue;
|
|
119
|
+
const endAt = new Date((toMin(w) + lenMin) * 60_000);
|
|
120
|
+
const end: Wall = { y: endAt.getUTCFullYear(), m: endAt.getUTCMonth() + 1, d: endAt.getUTCDate(), hh: endAt.getUTCHours(), mm: endAt.getUTCMinutes(), dateOnly: w.dateOnly };
|
|
121
|
+
const when = e.allDay
|
|
122
|
+
? [`RECURRENCE-ID;VALUE=DATE:${dateValue(w)}`, `DTSTART;VALUE=DATE:${dateValue(w)}`, `DTEND;VALUE=DATE:${dateValue(end)}`]
|
|
123
|
+
: [`RECURRENCE-ID;TZID=${tz}:${localValue(w)}`, `DTSTART;TZID=${tz}:${localValue(w)}`, `DTEND;TZID=${tz}:${localValue(end)}`];
|
|
124
|
+
out.push('BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
|
|
125
|
+
`SUMMARY:${escapeText(e.title || 'Untitled event')}`, `URL:${url}`, 'STATUS:CANCELLED', ...stamps, 'END:VEVENT');
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
106
130
|
export function buildFeed(events: CalEvent[], siteTz: string, opts: FeedOptions): string {
|
|
107
131
|
const lines = [
|
|
108
132
|
'BEGIN:VCALENDAR',
|
|
@@ -3,13 +3,15 @@
|
|
|
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
|
+
// Cancelling one date of a series applies at once too, without touching any
|
|
7
|
+
// staged edits.
|
|
6
8
|
import type { ContentRepository } from '../repo/types';
|
|
7
9
|
import type { CalEvent } from '../schema';
|
|
8
10
|
import { EventLocation, slugify } from '../schema';
|
|
9
11
|
import { defaultPlaceLocation } from '../maps/places';
|
|
10
12
|
import { ApiError } from '../api';
|
|
11
|
-
import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
|
|
12
|
-
import { addWallDays, formatWall, parseWall } from './events';
|
|
13
|
+
import { applyEventUpdate, eventSideEffects, saveEvent, type SaveHooks } from '../content-service';
|
|
14
|
+
import { addWallDays, findOccurrence, formatWall, parseWall } from './events';
|
|
13
15
|
import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
|
|
14
16
|
import { eventDeleted } from './hooks';
|
|
15
17
|
import { waitUntilFrom } from '../webhooks';
|
|
@@ -104,10 +106,15 @@ export async function moveSeries(repo: ContentRepository, id: string, days: numb
|
|
|
104
106
|
if (!e) throw new ApiError(404, 'Event not found.');
|
|
105
107
|
if (!Number.isInteger(days) || Math.abs(days) > 3660) throw new ApiError(400, 'Pick a day to move it to.');
|
|
106
108
|
const start = shift(e.start, days);
|
|
107
|
-
|
|
109
|
+
let event = await saveNow(repo, e, {
|
|
108
110
|
start, end: shift(e.end, days), allDay: e.allDay,
|
|
109
111
|
...(e.rrule ? { rrule: shiftRrule(e.rrule, days, start), exdates: e.exdates.map((x) => shift(x, days)) } : {}),
|
|
110
112
|
}, hooks);
|
|
113
|
+
// cancelled dates move with the series, like skipped ones
|
|
114
|
+
if (e.rrule && e.cancelledDates.length) {
|
|
115
|
+
await repo.setEventCancelledDates(id, e.cancelledDates.map((x) => shift(x, days)));
|
|
116
|
+
event = (await repo.getEvent(id))!;
|
|
117
|
+
}
|
|
111
118
|
return { event, undo: { kind: 'series', id, days: -days } };
|
|
112
119
|
}
|
|
113
120
|
|
|
@@ -135,7 +142,9 @@ export async function moveOccurrence(repo: ContentRepository, id: string, occurr
|
|
|
135
142
|
const copy = await repo.createEvent({
|
|
136
143
|
title: e.title, slug: await uniqueSlug(repo, e.title), start: formatWall(newStart), end: endStr, allDay: e.allDay,
|
|
137
144
|
timezone: e.timezone, status: 'draft', excerpt: e.excerpt, bodyJson: e.bodyJson, bodyHtml: e.bodyHtml, cover: e.cover, coverAlt: e.coverAlt,
|
|
138
|
-
location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags,
|
|
145
|
+
location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags,
|
|
146
|
+
// a cancelled date stays cancelled on its new day
|
|
147
|
+
cancelled: e.cancelled || e.cancelledDates.includes(occurrence), rsvp: e.rsvp,
|
|
139
148
|
template: e.template, featured: false,
|
|
140
149
|
});
|
|
141
150
|
const event = live(e.status) ? await saveEvent(repo, copy.id, { status: 'published' }, hooks) : copy;
|
|
@@ -156,6 +165,38 @@ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: S
|
|
|
156
165
|
if (series) await saveNow(repo, series, { exdates: series.exdates.filter((x) => x !== undo.occurrence) }, hooks);
|
|
157
166
|
}
|
|
158
167
|
|
|
168
|
+
// ---- cancel one date ----------------------------------------------------------
|
|
169
|
+
// The date stays on the calendar struck through ("Cancelled"), its room holds
|
|
170
|
+
// are released, and feeds mark it STATUS:CANCELLED. These do no permission
|
|
171
|
+
// checks: callers (admin actions, site code through @acms-subir/core/server)
|
|
172
|
+
// decide who may do this.
|
|
173
|
+
|
|
174
|
+
async function setOccurrenceCancelled(repo: ContentRepository, id: string, key: string, cancel: boolean, hooks: SaveHooks, now = Date.now()): Promise<CalEvent> {
|
|
175
|
+
const e = await repo.getEvent(id);
|
|
176
|
+
if (!e) throw new ApiError(404, 'Event not found.');
|
|
177
|
+
if (!e.rrule) throw new ApiError(400, 'Only a repeating event has single dates to cancel. Cancel the whole event instead.');
|
|
178
|
+
const settings = await repo.getSettings();
|
|
179
|
+
const o = typeof key === 'string' && key.length <= 20 ? findOccurrence(e, key, settings) : null;
|
|
180
|
+
if (!o) throw new ApiError(400, 'That date isn’t part of this event.');
|
|
181
|
+
if (o.start <= now) throw new ApiError(400, 'That date has already started or passed.');
|
|
182
|
+
const has = e.cancelledDates.includes(key);
|
|
183
|
+
if (has === cancel) return e; // already so: nothing to change
|
|
184
|
+
await repo.setEventCancelledDates(id, cancel ? [...e.cancelledDates, key].sort() : e.cancelledDates.filter((x) => x !== key));
|
|
185
|
+
const after = (await repo.getEvent(id))!;
|
|
186
|
+
await eventSideEffects(repo, hooks, e, after);
|
|
187
|
+
return after;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Cancel one date of a repeating event, at once (also on a published event). Idempotent. */
|
|
191
|
+
export function cancelOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number): Promise<CalEvent> {
|
|
192
|
+
return setOccurrenceCancelled(repo, id, key, true, hooks, now);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Undo cancelOccurrence for one date. Idempotent. */
|
|
196
|
+
export function restoreOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number): Promise<CalEvent> {
|
|
197
|
+
return setOccurrenceCancelled(repo, id, key, false, hooks, now);
|
|
198
|
+
}
|
|
199
|
+
|
|
159
200
|
export async function duplicateEvent(repo: ContentRepository, id: string): Promise<CalEvent> {
|
|
160
201
|
const e = await repo.getEvent(id);
|
|
161
202
|
if (!e) throw new ApiError(404, 'Event not found.');
|
package/src/lib/calendar/seo.ts
CHANGED
|
@@ -17,7 +17,7 @@ export function isoWithOffset(ms: number, tz: string): string {
|
|
|
17
17
|
|
|
18
18
|
const isMeetingUrl = (u: string) => /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si|youtube\.com\/live|twitch\.tv/i.test(u);
|
|
19
19
|
|
|
20
|
-
export function eventJsonLd(e: CalEvent, o: Pick<Occurrence, 'start' | 'end' | 'allDay'
|
|
20
|
+
export function eventJsonLd(e: CalEvent, o: Pick<Occurrence, 'start' | 'end' | 'allDay'> & { cancelled?: boolean }, settings: Settings, origin: string, pageUrl: string) {
|
|
21
21
|
const tz = eventTimeZone(e, settings);
|
|
22
22
|
const date = (ms: number) => {
|
|
23
23
|
const p = tzParts(ms, tz);
|
|
@@ -39,7 +39,7 @@ export function eventJsonLd(e: CalEvent, o: Pick<Occurrence, 'start' | 'end' | '
|
|
|
39
39
|
...(e.excerpt || e.seoDesc ? { description: e.seoDesc || e.excerpt } : {}),
|
|
40
40
|
startDate: o.allDay ? date(o.start) : isoWithOffset(o.start, tz),
|
|
41
41
|
endDate: o.allDay ? date(o.end - 86_400_000) : isoWithOffset(o.end, tz),
|
|
42
|
-
eventStatus: e.cancelled ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',
|
|
42
|
+
eventStatus: e.cancelled || o.cancelled ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',
|
|
43
43
|
eventAttendanceMode: online && place
|
|
44
44
|
? 'https://schema.org/MixedEventAttendanceMode'
|
|
45
45
|
: online ? 'https://schema.org/OnlineEventAttendanceMode' : 'https://schema.org/OfflineEventAttendanceMode',
|
|
@@ -35,7 +35,9 @@ export type PostSave = Parameters<ContentRepository['updatePost']>[1];
|
|
|
35
35
|
export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blocks'>;
|
|
36
36
|
// location may be partial: missing pin fields are filled from the current
|
|
37
37
|
// location (see mergeLocationInput) — older clients send name/address/url only
|
|
38
|
-
|
|
38
|
+
// cancelledDates is left out: single dates are cancelled at once through
|
|
39
|
+
// cancelOccurrence/restoreOccurrence (lib/calendar/schedule.ts), never staged
|
|
40
|
+
export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location' | 'cancelledDates'>> & {
|
|
39
41
|
location?: Partial<EventLocation>;
|
|
40
42
|
};
|
|
41
43
|
type EventClean = Omit<EventSave, 'location'> & { location?: EventLocation };
|
|
@@ -200,7 +202,7 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
|
|
|
200
202
|
return out;
|
|
201
203
|
}
|
|
202
204
|
|
|
203
|
-
async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, before: CalEvent, after: CalEvent, applied = false) {
|
|
205
|
+
export async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, before: CalEvent, after: CalEvent, applied = false) {
|
|
204
206
|
const settings = await repo.getSettings();
|
|
205
207
|
await eventChanged(repo, before, after, {
|
|
206
208
|
origin: siteOrigin(settings, hooks.url), waitUntil: waitUntilFrom(hooks.locals), applied, onRedirect: hooks.onRedirect,
|
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
3
|
import type { BookingLink, CalEvent, Calendar, Resource, Settings } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
|
-
import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl } from '../calendar/config';
|
|
5
|
+
import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
|
|
6
6
|
import { calendarToJson, eventToJson } from '../calendar/api';
|
|
7
7
|
import { eventIsListed, eventIsPublic, expandEvents } from '../calendar/events';
|
|
8
8
|
import { availabilityContext } from '../booking/service';
|
|
@@ -54,6 +54,7 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
54
54
|
locale, timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
|
|
55
55
|
occurrences: occurrences.slice(start, start + q.limit).map(o => ({
|
|
56
56
|
id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay,
|
|
57
|
+
cancelled: o.cancelled,
|
|
57
58
|
event: publicEventDto(o.event, origin, publicCalendars, settings.timezone, locale),
|
|
58
59
|
})),
|
|
59
60
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
@@ -63,8 +64,8 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
63
64
|
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, siteTz: string, locale: string | null) {
|
|
64
65
|
const event = eventToJson(e, origin, publicCalendars, siteTz);
|
|
65
66
|
event.url = localizedUrl(event.url, origin, locale);
|
|
66
|
-
// Only configured public event fields; no undeclared imported metadata.
|
|
67
|
-
event.fields =
|
|
67
|
+
// Only configured public event fields; no undeclared imported metadata, no private fields.
|
|
68
|
+
event.fields = publicEventFields(e.fields);
|
|
68
69
|
event.image = safeUrl(e.cover, origin);
|
|
69
70
|
event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
|
|
70
71
|
return event;
|
|
@@ -28,18 +28,18 @@ export const Page = ContentSummary.extend({
|
|
|
28
28
|
});
|
|
29
29
|
export const Event = CalEvent.pick({
|
|
30
30
|
id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
|
|
31
|
-
allDay: true, timezone: true, rrule: true, exdates: true, location: true, cta: true, tags: true, fields: true,
|
|
31
|
+
allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
|
|
32
32
|
}).extend({ rsvp: CalEvent.shape.rsvp.optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
|
|
33
33
|
calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
|
|
34
34
|
});
|
|
35
35
|
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
36
|
-
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, event: Event })),
|
|
36
|
+
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, event: Event })),
|
|
37
37
|
});
|
|
38
38
|
const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
|
|
39
39
|
const ContentHit = ContentSummary.extend({ snippet: Snippet });
|
|
40
40
|
export const SearchResults = z.object({ locale, query: str,
|
|
41
41
|
posts: z.array(ContentHit), pages: z.array(ContentHit),
|
|
42
|
-
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool }).nullable(), event: Event })),
|
|
42
|
+
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event })),
|
|
43
43
|
more: z.object({ posts: bool, pages: bool, events: bool }),
|
|
44
44
|
});
|
|
45
45
|
const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
|
package/src/lib/mobile/search.ts
CHANGED
|
@@ -86,7 +86,7 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
86
86
|
return {
|
|
87
87
|
snippet: plainSnippet(snippet),
|
|
88
88
|
// the next occurrence, or the latest one when the event is over
|
|
89
|
-
occurrence: o ? { start: iso(o.start), end: iso(o.end), allDay: o.allDay } : null,
|
|
89
|
+
occurrence: o ? { start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled } : null,
|
|
90
90
|
event: publicEventDto(e, origin, listed, settings.timezone, locale),
|
|
91
91
|
};
|
|
92
92
|
}),
|
|
@@ -655,9 +655,20 @@ export const MIGRATIONS: Migration[] = [
|
|
|
655
655
|
`DROP TRIGGER content_version_settings_update`,
|
|
656
656
|
...contentVersionTriggers('settings', [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale']),
|
|
657
657
|
] },
|
|
658
|
+
// one cancelled date of a repeating event (CalEvent.cancelledDates)
|
|
659
|
+
{ version: 55, statements: [
|
|
660
|
+
`ALTER TABLE events ADD COLUMN cancelled_dates TEXT NOT NULL DEFAULT '[]'`,
|
|
661
|
+
`DROP TRIGGER content_version_events_update`,
|
|
662
|
+
// [insert, delete, update]: only the update trigger names columns
|
|
663
|
+
contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'])[2],
|
|
664
|
+
] },
|
|
658
665
|
];
|
|
659
666
|
|
|
660
667
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
|
661
|
-
export const CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
668
|
+
export const CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
669
|
+
...CONTENT_VERSION_COLUMNS,
|
|
670
|
+
settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale'],
|
|
671
|
+
events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
|
|
672
|
+
};
|
|
662
673
|
|
|
663
674
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -149,7 +149,7 @@ function rowToEvent(r: Row): CalEvent {
|
|
|
149
149
|
seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
|
|
150
150
|
featured: Boolean(r.featured), tags: json(r.tags, []), template: r.template ?? '',
|
|
151
151
|
start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
|
|
152
|
-
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
|
|
152
|
+
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []),
|
|
153
153
|
location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
|
|
154
154
|
fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
|
|
155
155
|
startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
|
|
@@ -833,8 +833,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
833
833
|
await this.driver.run(
|
|
834
834
|
`INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
835
835
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
836
|
-
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
|
|
837
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
836
|
+
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates)
|
|
837
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
838
838
|
[
|
|
839
839
|
id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
|
|
840
840
|
data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
|
|
@@ -842,7 +842,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
842
842
|
data.cancelled ? 1 : 0, JSON.stringify(data.location ?? {}), JSON.stringify(data.cta ?? {}), JSON.stringify(data.calendarIds ?? []),
|
|
843
843
|
JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
|
|
844
844
|
data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
|
|
845
|
-
JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}),
|
|
845
|
+
JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
|
|
846
846
|
]
|
|
847
847
|
);
|
|
848
848
|
await this.syncEventIndex(id);
|
|
@@ -867,7 +867,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
867
867
|
if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
|
|
868
868
|
}
|
|
869
869
|
const jsonCols: Record<string, string> = {
|
|
870
|
-
tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
|
|
870
|
+
tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
|
|
871
871
|
};
|
|
872
872
|
for (const [k, col] of Object.entries(jsonCols)) {
|
|
873
873
|
const v = (data as Record<string, unknown>)[k];
|
|
@@ -927,6 +927,11 @@ export class SqlRepository implements ContentRepository {
|
|
|
927
927
|
if (!draft) return;
|
|
928
928
|
await this.updateEvent(id, pickEventDraft(draft));
|
|
929
929
|
}
|
|
930
|
+
// per-date cancellations apply at once and never touch a staged draft
|
|
931
|
+
async setEventCancelledDates(id: string, dates: string[]) {
|
|
932
|
+
await this.ready;
|
|
933
|
+
await this.driver.run('UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', [JSON.stringify(dates), Date.now(), id]);
|
|
934
|
+
}
|
|
930
935
|
async discardEventDraft(id: string) {
|
|
931
936
|
await this.ready;
|
|
932
937
|
await this.driver.run('UPDATE events SET draft = NULL WHERE id = ?', [id]);
|
|
@@ -1779,14 +1784,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
1779
1784
|
...(content.events ?? []).map((e) => ({
|
|
1780
1785
|
sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
1781
1786
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
1782
|
-
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
|
|
1783
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1787
|
+
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates)
|
|
1788
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1784
1789
|
args: [
|
|
1785
1790
|
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,
|
|
1786
1791
|
JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
|
|
1787
1792
|
e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
|
|
1788
1793
|
e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
|
|
1789
|
-
JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}),
|
|
1794
|
+
JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}), JSON.stringify(e.cancelledDates ?? []),
|
|
1790
1795
|
],
|
|
1791
1796
|
})),
|
|
1792
1797
|
...(content.forms ?? []).map(formInsert),
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -45,7 +45,7 @@ export type EventUpdate = Partial<
|
|
|
45
45
|
CalEvent,
|
|
46
46
|
| 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
|
|
47
47
|
| 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
|
|
48
|
-
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
|
|
48
|
+
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates'
|
|
49
49
|
| 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
|
|
50
50
|
| 'publishedAt' | 'scheduledAt'
|
|
51
51
|
>
|
|
@@ -143,6 +143,8 @@ export interface ContentRepository {
|
|
|
143
143
|
saveEventDraft(id: string, data: Omit<EventUpdate, 'status' | 'scheduledAt' | 'publishedAt'>): Promise<void>;
|
|
144
144
|
applyEventDraft(id: string): Promise<void>;
|
|
145
145
|
discardEventDraft(id: string): Promise<void>;
|
|
146
|
+
/** replace an event's cancelled occurrence keys at once, keeping any staged draft */
|
|
147
|
+
setEventCancelledDates(id: string, dates: string[]): Promise<void>;
|
|
146
148
|
deleteEvent(id: string): Promise<void>;
|
|
147
149
|
// saved-place sync (lib/maps/places.ts): rewrite just the location of the
|
|
148
150
|
// live row and/or the staged draft, leaving every other field — and the
|
package/src/lib/schema.ts
CHANGED
|
@@ -365,6 +365,10 @@ export const CalEvent = z.object({
|
|
|
365
365
|
// a cancelled event stays visible (struck through) so people aren't left
|
|
366
366
|
// wondering where it went; feeds emit STATUS:CANCELLED
|
|
367
367
|
cancelled: z.boolean().default(false),
|
|
368
|
+
// occurrence keys (same format as exdates) of single dates that are
|
|
369
|
+
// cancelled: shown struck through, but they don't hold rooms. Keys that no
|
|
370
|
+
// longer match an occurrence (the series moved) are ignored.
|
|
371
|
+
cancelledDates: z.array(z.string()).default([]),
|
|
368
372
|
location: EventLocation.default({}),
|
|
369
373
|
cta: EventCta.default({}),
|
|
370
374
|
calendarIds: z.array(z.string()).default([]),
|
package/src/lib/sections.ts
CHANGED
|
@@ -144,8 +144,8 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
144
144
|
return {
|
|
145
145
|
title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
|
|
146
146
|
location: locationLine(e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
147
|
-
allDay: o.allDay, cancelled:
|
|
148
|
-
cta: e.cta.label && ctaHref && !
|
|
147
|
+
allDay: o.allDay, cancelled: o.cancelled, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
|
|
148
|
+
cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
|
|
149
149
|
};
|
|
150
150
|
});
|
|
151
151
|
}
|
package/src/lib/sync/catalog.ts
CHANGED
|
@@ -18,7 +18,7 @@ Errors: { "error": "message" } with 400 invalid · 401 no/invalid key · 403 mis
|
|
|
18
18
|
## Events
|
|
19
19
|
|
|
20
20
|
GET /api/calendar/events?from=YYYY-MM-DD&to=YYYY-MM-DD&calendar=<slug>&q=<text>&limit=200
|
|
21
|
-
→ { timezone, from, to, occurrences: [{ id, start, end, allDay, event }] } (repeats expanded)
|
|
21
|
+
→ { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] } (repeats expanded; cancelled: the whole event or just that date, listed in event.cancelledDates)
|
|
22
22
|
GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
|
|
23
23
|
GET /api/calendar/events/<id|slug>
|
|
24
24
|
POST /api/calendar/events (events:write) → creates a DRAFT
|
|
@@ -32,7 +32,7 @@ export const GET: APIRoute = async ({ request, locals, url, params }) => {
|
|
|
32
32
|
const full = who ? hasScope(who, 'events:read') : false;
|
|
33
33
|
if (!e || (!full && !eventIsPublic(e, map))) throw new ApiError(404, 'Event not found.');
|
|
34
34
|
const view = full ? mergeEventDraft(e) : e;
|
|
35
|
-
return withCors(json({ event: { ...eventToJson(view, siteOrigin(settings, url), map, settings.timezone), ...(full ? { hasPendingDraft: Boolean(e.draft), bodyHtml: view.bodyHtml } : {}) } }));
|
|
35
|
+
return withCors(json({ event: { ...eventToJson(view, siteOrigin(settings, url), map, settings.timezone, { includePrivate: full }), ...(full ? { hasPendingDraft: Boolean(e.draft), bodyHtml: view.bodyHtml } : {}) } }));
|
|
36
36
|
} catch (err) { return fail(err); }
|
|
37
37
|
};
|
|
38
38
|
|
|
@@ -17,10 +17,11 @@ import { fail } from '../../../../lib/calendar/http';
|
|
|
17
17
|
|
|
18
18
|
// Public read API for other sites, apps and lobby screens.
|
|
19
19
|
// GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
|
|
20
|
-
// → { timezone, from, to, occurrences: [{ id, start, end, allDay, event }] }
|
|
20
|
+
// → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] }
|
|
21
21
|
// GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
|
|
22
22
|
// Anonymous callers see public calendars (and an unlisted one when asked by
|
|
23
|
-
// slug). A key with events:read also sees private calendars
|
|
23
|
+
// slug). A key with events:read also sees private calendars and private
|
|
24
|
+
// custom fields.
|
|
24
25
|
const CORS = {
|
|
25
26
|
'Access-Control-Allow-Origin': '*',
|
|
26
27
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
@@ -56,7 +57,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
|
56
57
|
if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
|
|
57
58
|
|
|
58
59
|
if (url.searchParams.get('expand') === '0') {
|
|
59
|
-
return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone)) }));
|
|
60
|
+
return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full })) }));
|
|
60
61
|
}
|
|
61
62
|
const day = (param: string, fallback: number) => {
|
|
62
63
|
const w = parseWall(url.searchParams.get(param) ?? '');
|
|
@@ -70,7 +71,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
|
70
71
|
const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to }).slice(0, limit);
|
|
71
72
|
const cache = new Map<string, ReturnType<typeof eventToJson>>();
|
|
72
73
|
const toJson = (id: string) => {
|
|
73
|
-
if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone));
|
|
74
|
+
if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone, { includePrivate: full }));
|
|
74
75
|
return cache.get(id)!;
|
|
75
76
|
};
|
|
76
77
|
return withCors(json({
|
|
@@ -84,6 +85,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
|
84
85
|
start: o.allDay ? isoWithOffset(o.start, etz).slice(0, 10) : isoWithOffset(o.start, etz),
|
|
85
86
|
end: o.allDay ? isoWithOffset(o.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.end, etz),
|
|
86
87
|
allDay: o.allDay,
|
|
88
|
+
cancelled: o.cancelled,
|
|
87
89
|
event: toJson(o.event.id),
|
|
88
90
|
};
|
|
89
91
|
}),
|
|
@@ -166,7 +166,7 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
|
|
|
166
166
|
{view !== 'list' && comingUp.length > 0 && (
|
|
167
167
|
<section class="pc-calpage-up">
|
|
168
168
|
<h2>Coming up</h2>
|
|
169
|
-
<div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days }, settings, { showImage: true })).join('')} />
|
|
169
|
+
<div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: v.cancelled }, settings, { showImage: true })).join('')} />
|
|
170
170
|
</section>
|
|
171
171
|
)}
|
|
172
172
|
|
|
@@ -16,7 +16,7 @@ import { validateRequestSession } from '../../lib/auth/request';
|
|
|
16
16
|
import { effectiveSettings } from '../../lib/designPreview';
|
|
17
17
|
import { resolveDesign } from '../../lib/theme';
|
|
18
18
|
import { siteOrigin } from '../../lib/seo';
|
|
19
|
-
import { CALENDAR, eventUrl } from '../../lib/calendar/config';
|
|
19
|
+
import { CALENDAR, eventUrl, withoutPrivateFields } from '../../lib/calendar/config';
|
|
20
20
|
import { eventIsPublic, featuredOccurrence } from '../../lib/calendar/events';
|
|
21
21
|
import { eventJsonLd } from '../../lib/calendar/seo';
|
|
22
22
|
import { upcomingEvents } from '../../lib/calendar/cards';
|
|
@@ -31,7 +31,8 @@ if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not
|
|
|
31
31
|
const repo = await getRepo(Astro.locals);
|
|
32
32
|
const locale = requestLocale(Astro.url, Astro.locals);
|
|
33
33
|
const rawEvent = await repo.getEventBySlug(Astro.params.slug ?? '');
|
|
34
|
-
|
|
34
|
+
// private custom fields never reach the page or a theme's event template
|
|
35
|
+
const event = rawEvent ? withoutPrivateFields(localizeDoc(rawEvent, locale)) : null;
|
|
35
36
|
const [rawSettings, allCalendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
36
37
|
const calMap = new Map(allCalendars.map((c) => [c.id, c]));
|
|
37
38
|
const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
|
|
@@ -68,7 +69,7 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
|
|
|
68
69
|
const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
|
|
69
70
|
const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
|
|
70
71
|
const rsvp = event.rsvp.enabled
|
|
71
|
-
? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues, open: occurrence.end > now && !
|
|
72
|
+
? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues, open: occurrence.end > now && !occurrence.cancelled }
|
|
72
73
|
: null;
|
|
73
74
|
const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
|
|
74
75
|
const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
|