@acms-subir/core 0.3.19 → 0.3.21
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 +20 -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/admin/email.astro +3 -3
- package/src/pages/admin/live.astro +3 -3
- package/src/pages/admin/mobile.astro +3 -3
- 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
|
@@ -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));
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @acms-subir/core/server — the small, stable API site code (Astro pages,
|
|
2
|
+
// endpoints and actions in the site's own src/) uses to read events, find
|
|
3
|
+
// the signed-in person and cancel single dates. It wraps the internals so
|
|
4
|
+
// they can change without breaking sites; everything here is a public
|
|
5
|
+
// contract (see docs/server.md, `acms docs server`). Nothing in this module
|
|
6
|
+
// checks permissions on writes: callers decide who may change what.
|
|
7
|
+
import type { AstroCookies } from 'astro';
|
|
8
|
+
import type { ContentRepository } from '../lib/repo/types';
|
|
9
|
+
import type { CalEvent, Role } from '../lib/schema';
|
|
10
|
+
import type { Occurrence } from '../lib/calendar/events';
|
|
11
|
+
|
|
12
|
+
/** A handle to the site's content store. Pass it to the functions below. */
|
|
13
|
+
export interface Repo {
|
|
14
|
+
readonly __acmsRepo: true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The signed-in person (null when signed out or removed). */
|
|
18
|
+
export interface ServerUser {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string;
|
|
21
|
+
name: string;
|
|
22
|
+
role: Role;
|
|
23
|
+
/** whether their role grants a permission key (e.g. 'content.edit'); unknown keys are false */
|
|
24
|
+
can(permission: string): boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type { CalEvent, Occurrence };
|
|
28
|
+
|
|
29
|
+
export interface ListEventsOptions {
|
|
30
|
+
/** only published events (default: every event except archived ones) */
|
|
31
|
+
publishedOnly?: boolean;
|
|
32
|
+
/** only events in this calendar (id or slug) */
|
|
33
|
+
calendar?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OccurrenceWindow {
|
|
37
|
+
/** one event's occurrences; omit for every event (archived ones excluded) */
|
|
38
|
+
eventId?: string;
|
|
39
|
+
/** window start and end, as a Date or Unix milliseconds; at most 400 days */
|
|
40
|
+
from: Date | number;
|
|
41
|
+
to: Date | number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where a write came from: used for webhook URLs and background work. */
|
|
45
|
+
export interface WriteContext {
|
|
46
|
+
url: URL;
|
|
47
|
+
locals: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const internal = (repo: Repo) => repo as unknown as ContentRepository;
|
|
51
|
+
const MAX_WINDOW = 400 * 86_400_000;
|
|
52
|
+
|
|
53
|
+
/** The site's repository for this request (`Astro.locals` or an endpoint's `locals`). */
|
|
54
|
+
export async function getRepo(locals: unknown): Promise<Repo> {
|
|
55
|
+
const { getRepo: load } = await import('../lib/repo');
|
|
56
|
+
return (await load(locals)) as unknown as Repo;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The person signed in to the admin on this request, on any page (public
|
|
61
|
+
* pages included), or null. With Clerk sign-in, a signed-in browser carries
|
|
62
|
+
* the `__session` cookie and is resolved on every page except /media,
|
|
63
|
+
* /embed and /api/v1.
|
|
64
|
+
*/
|
|
65
|
+
export async function currentUser(context: { request: Request; cookies: AstroCookies | { get(name: string): { value: string } | undefined }; locals: unknown }): Promise<ServerUser | null> {
|
|
66
|
+
const [{ getRequestAuth }, { accessFor }, { isPermission }, { OWNER_ENV }, { getRepo: load }] = await Promise.all([
|
|
67
|
+
import('../lib/auth/request'), import('../lib/permissions/access'), import('../lib/permissions/catalog'), import('../lib/auth'), import('../lib/repo'),
|
|
68
|
+
]);
|
|
69
|
+
// with locals first, so Clerk reads this deployment's keys (cached per request)
|
|
70
|
+
await getRequestAuth(context.request, context.cookies, context.locals);
|
|
71
|
+
const repo = await load(context.locals);
|
|
72
|
+
const access = await accessFor(context.request, context.cookies, repo);
|
|
73
|
+
if (!access) return null;
|
|
74
|
+
const user = access.userId === OWNER_ENV ? null : await repo.getUser(access.userId);
|
|
75
|
+
return {
|
|
76
|
+
id: access.userId,
|
|
77
|
+
email: user?.email ?? '',
|
|
78
|
+
name: access.name,
|
|
79
|
+
role: access.role,
|
|
80
|
+
can: (permission: string) => isPermission(permission) && access.can(permission),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Events by first start date (earliest first), with their live values (staged edits are not merged in). */
|
|
85
|
+
export async function listEvents(repo: Repo, opts: ListEventsOptions = {}): Promise<CalEvent[]> {
|
|
86
|
+
const r = internal(repo);
|
|
87
|
+
let events = opts.publishedOnly ? await r.listPublishedEvents() : (await r.listEvents()).filter((e) => e.status !== 'archived');
|
|
88
|
+
if (opts.calendar) {
|
|
89
|
+
const cal = (await r.getCalendar(opts.calendar)) ?? (await r.getCalendarBySlug(opts.calendar));
|
|
90
|
+
if (!cal) return [];
|
|
91
|
+
events = events.filter((e) => e.calendarIds.includes(cal.id));
|
|
92
|
+
}
|
|
93
|
+
return events;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One event by id (null when it doesn't exist). */
|
|
97
|
+
export async function getEvent(repo: Repo, id: string): Promise<CalEvent | null> {
|
|
98
|
+
return (await internal(repo).getEvent(id)) ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Every occurrence in [from, to), soonest first. Each carries its `key`
|
|
103
|
+
* (the occurrence key cancelOccurrence takes) and `cancelled` (the whole
|
|
104
|
+
* event, or just that date). Drafts are included: check `o.event.status`.
|
|
105
|
+
*/
|
|
106
|
+
export async function listOccurrences(repo: Repo, window: OccurrenceWindow): Promise<Occurrence[]> {
|
|
107
|
+
const from = +window.from, to = +window.to;
|
|
108
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw new RangeError('listOccurrences needs a "to" after "from".');
|
|
109
|
+
if (to - from > MAX_WINDOW) throw new RangeError('listOccurrences windows are at most 400 days.');
|
|
110
|
+
const r = internal(repo);
|
|
111
|
+
const [{ expandEvents }, settings] = await Promise.all([import('../lib/calendar/events'), r.getSettings()]);
|
|
112
|
+
let events: CalEvent[];
|
|
113
|
+
if (window.eventId) {
|
|
114
|
+
const e = await r.getEvent(window.eventId);
|
|
115
|
+
events = e ? [e] : [];
|
|
116
|
+
} else {
|
|
117
|
+
events = (await r.listEvents()).filter((e) => e.status !== 'archived');
|
|
118
|
+
}
|
|
119
|
+
return expandEvents(events, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Cancel one date of a repeating event, at once, even on a published event:
|
|
124
|
+
* it stays listed struck through as "Cancelled", its room holds are freed,
|
|
125
|
+
* feeds and the app see it cancelled, and the event.updated webhook fires.
|
|
126
|
+
* Idempotent. Throws an Error with `status` 404 for an unknown event, or 400
|
|
127
|
+
* for an event that doesn't repeat, a key that isn't one of its dates, or a
|
|
128
|
+
* date that has started or passed; the message is safe to show people.
|
|
129
|
+
*
|
|
130
|
+
* Does NOT check permissions: check the caller's access first.
|
|
131
|
+
*/
|
|
132
|
+
export async function cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, context: WriteContext): Promise<CalEvent> {
|
|
133
|
+
const { cancelOccurrence: cancel } = await import('../lib/calendar/schedule');
|
|
134
|
+
return cancel(internal(repo), eventId, occurrenceKey, { url: context.url, locals: context.locals });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Undo cancelOccurrence for one date. Same rules; idempotent; no permission checks. */
|
|
138
|
+
export async function restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, context: WriteContext): Promise<CalEvent> {
|
|
139
|
+
const { restoreOccurrence: restore } = await import('../lib/calendar/schedule');
|
|
140
|
+
return restore(internal(repo), eventId, occurrenceKey, { url: context.url, locals: context.locals });
|
|
141
|
+
}
|
|
@@ -22,8 +22,12 @@ export interface UpcomingEventsOptions {
|
|
|
22
22
|
horizonDays?: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Occurrence start/end are Unix milliseconds; event retains its authored
|
|
27
|
+
* fields except private custom fields. `cancelled` is true when the whole
|
|
28
|
+
* event or just this date is cancelled.
|
|
29
|
+
*/
|
|
30
|
+
export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>;
|
|
27
31
|
|
|
28
32
|
/** Server-side helper for Astro templates and endpoints. No repository access needed. */
|
|
29
33
|
export async function upcomingEvents(
|
package/src/virtual.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ declare module 'virtual:acms-config' {
|
|
|
29
29
|
bookBase: string;
|
|
30
30
|
resourceKinds: string[];
|
|
31
31
|
defaultView: 'month' | 'week' | 'list';
|
|
32
|
-
eventFields: { key: string; label: string; type: 'text' | 'textarea' | 'number' | 'url' | 'select'; options: string[] }[];
|
|
32
|
+
eventFields: { key: string; label: string; type: 'text' | 'textarea' | 'number' | 'url' | 'select'; options: string[]; private: boolean }[];
|
|
33
33
|
};
|
|
34
34
|
};
|
|
35
35
|
}
|