@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.21
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6096e7c: Fix the Live video, Mobile app and Email admin pages refusing owners who opened them from a link on another site or app. These pages ran the API's cross-site check and then reported every failure as a missing "Connected services" permission (Email: "Only owners can manage email"). They now check only the person's role, like the other admin pages. The admin APIs still refuse cross-site calls.
|
|
8
|
+
|
|
9
|
+
## 0.3.20
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 531c632: Cancel a single date of a repeating event. The new `cancelledDates` event field stores occurrence keys in the same wall-clock format as `exdates` (`YYYY-MM-DDTHH:mm` in the event's timezone, or `YYYY-MM-DD` for all-day events). A cancelled date stays on the calendar struck through as "Cancelled", and the room it holds becomes bookable for that slot. RSVPs for it close. The event page, cards, sections, `/api/v1/events`, `/api/calendar/events` and the app mark that occurrence `cancelled`. ICS feeds add a `RECURRENCE-ID` override with `STATUS:CANCELLED`, and webhook payloads include `cancelledDates`. The admin Schedule adds "Cancel this date" and "Restore this date" on an occurrence. Moving a series moves its cancelled dates too. After a time or rule edit, keys that no longer match an occurrence are ignored. Migration 55 adds the column (default `[]`) to existing databases.
|
|
14
|
+
|
|
15
|
+
Add `@acms-subir/core/server`, a small, documented API for site code: `getRepo`, `currentUser`, `listEvents`, `getEvent`, `listOccurrences`, `cancelOccurrence` and `restoreOccurrence`. `currentUser` works on public pages, including Clerk sessions. The cancel and restore functions apply at once, keep unpublished admin edits staged, are idempotent and do no authorization; callers must check access first. Run `acms docs server` for the guide and an example action.
|
|
16
|
+
|
|
17
|
+
Add private custom event fields: `calendar.eventFields: [{ key, label, type, private: true }]`. Private values are stored, editable in the admin and returned by `@acms-subir/core/server`. They are left out of the event page, theme templates and helpers, `/api/v1/events`, `/api/calendar/events` (except for API keys with `events:read`), webhooks, ICS and search. Public event JSON now carries only configured, non-private custom fields.
|
|
18
|
+
|
|
19
|
+
Client types add `cancelled` to event occurrences and `cancelledDates` to events.
|
|
20
|
+
|
|
21
|
+
Not included yet, but each would be a small addition to `@acms-subir/core/server`: signed links (core already signs sessions with HMAC), sending an email through the site's configured provider (the email queue already exists), and a `Site.astro` layout export (the layout already exists). Rate limiting would take more work, because the current limiter only covers sign-in.
|
|
22
|
+
|
|
3
23
|
## 0.3.19
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/cli/main.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { addMobile, mobileCommand, listApps } from './mobile.mjs';
|
|
|
10
10
|
import { mobileDoctor, renderDoctor } from './mobile-doctor.mjs';
|
|
11
11
|
import { monitorCommand, renderMonitor } from './monitor.mjs';
|
|
12
12
|
const HELP=`acms — ACMS project, theme and agent tools
|
|
13
|
-
Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync
|
|
13
|
+
Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server
|
|
14
14
|
agent init [--mobile] write .agents/skills (theme; mobile when an app exists)
|
|
15
15
|
Check: check [--build] validate every project theme, refresh section types,
|
|
16
16
|
report sections without a native app version
|
|
@@ -139,6 +139,7 @@ export async function main() {
|
|
|
139
139
|
else if(cmd==='docs'&&sub==='sections')data={markdown:await readFile(resolve(CORE,'docs/sections.md'),'utf8')};
|
|
140
140
|
else if(cmd==='docs'&&sub==='mobile')data={markdown:await readFile(resolve(CORE,'docs/mobile.md'),'utf8')};
|
|
141
141
|
else if(cmd==='docs'&&sub==='sync')data={markdown:await readFile(resolve(CORE,'docs/environment-sync.md'),'utf8')};
|
|
142
|
+
else if(cmd==='docs'&&sub==='server')data={markdown:await readFile(resolve(CORE,'docs/server.md'),'utf8')};
|
|
142
143
|
else if(cmd==='section')data=await sectionCommand(sub,rest,flags);
|
|
143
144
|
else if(cmd==='check') {
|
|
144
145
|
const p=await project(),themes=(await listLocalThemes(p)).filter(t=>t.origin==='project');
|
|
@@ -147,7 +148,7 @@ export async function main() {
|
|
|
147
148
|
}
|
|
148
149
|
else if(cmd==='agent'&&sub==='init')data=await agentInit(flags);
|
|
149
150
|
else if(cmd==='inspect') {
|
|
150
|
-
const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor','monitor-kuma','migrate'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
|
|
151
|
+
const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor','monitor-kuma','migrate','server-api'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
|
|
151
152
|
} else if(cmd==='theme')data=await themeCommand(sub,rest,flags);
|
|
152
153
|
else if(['site','design','content','media'].includes(cmd))data=await remoteCommand(cmd,sub,rest,flags);
|
|
153
154
|
else fail('USAGE','Unknown command.',[],['Run acms --help']);
|
|
@@ -1052,9 +1052,20 @@ var MIGRATIONS = [
|
|
|
1052
1052
|
BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
1053
1053
|
`DROP TRIGGER content_version_settings_update`,
|
|
1054
1054
|
...contentVersionTriggers("settings", [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale"])
|
|
1055
|
+
] },
|
|
1056
|
+
// one cancelled date of a repeating event (CalEvent.cancelledDates)
|
|
1057
|
+
{ version: 55, statements: [
|
|
1058
|
+
`ALTER TABLE events ADD COLUMN cancelled_dates TEXT NOT NULL DEFAULT '[]'`,
|
|
1059
|
+
`DROP TRIGGER content_version_events_update`,
|
|
1060
|
+
// [insert, delete, update]: only the update trigger names columns
|
|
1061
|
+
contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates"])[2]
|
|
1055
1062
|
] }
|
|
1056
1063
|
];
|
|
1057
|
-
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1064
|
+
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1065
|
+
...CONTENT_VERSION_COLUMNS,
|
|
1066
|
+
settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale"],
|
|
1067
|
+
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates"]
|
|
1068
|
+
};
|
|
1058
1069
|
var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
1059
1070
|
|
|
1060
1071
|
// src/lib/repo/migrate-runner.ts
|
package/docs/server.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Site server code: `@acms-subir/core/server`
|
|
2
|
+
|
|
3
|
+
A small, stable API for code in your site's own `src/`: Astro pages, endpoints and
|
|
4
|
+
actions. Use it to read events, find out who is signed in, and cancel single dates of
|
|
5
|
+
a repeating event. It is a public contract: these functions keep their signatures
|
|
6
|
+
across minor versions. Import only from `@acms-subir/core/server`. Deep imports into
|
|
7
|
+
`src/lib` are blocked by the package's exports map and can change at any time.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import {
|
|
11
|
+
getRepo, currentUser,
|
|
12
|
+
listEvents, getEvent, listOccurrences,
|
|
13
|
+
cancelOccurrence, restoreOccurrence,
|
|
14
|
+
} from '@acms-subir/core/server';
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Signatures
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
getRepo(locals: unknown): Promise<Repo>
|
|
21
|
+
currentUser(ctx: { request: Request; cookies: AstroCookies; locals: unknown }): Promise<ServerUser | null>
|
|
22
|
+
listEvents(repo: Repo, opts?: { publishedOnly?: boolean; calendar?: string }): Promise<CalEvent[]>
|
|
23
|
+
getEvent(repo: Repo, id: string): Promise<CalEvent | null>
|
|
24
|
+
listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number }): Promise<Occurrence[]>
|
|
25
|
+
cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: { url: URL; locals: unknown }): Promise<CalEvent>
|
|
26
|
+
restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: { url: URL; locals: unknown }): Promise<CalEvent>
|
|
27
|
+
|
|
28
|
+
interface ServerUser { id: string; email: string; name: string; role: Role; can(permission: string): boolean }
|
|
29
|
+
interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The types `Repo`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOptions`,
|
|
33
|
+
`OccurrenceWindow` and `WriteContext` are exported too.
|
|
34
|
+
|
|
35
|
+
- **`getRepo(locals)`**: the site's content store for this request. Pass `Astro.locals`
|
|
36
|
+
(or the endpoint/action's `locals`). `Repo` is an opaque handle; pass it to the other
|
|
37
|
+
functions.
|
|
38
|
+
- **`currentUser(ctx)`**: the person signed in to the admin, or `null`. It works on any
|
|
39
|
+
page, public ones included. With Clerk sign-in, a signed-in browser sends the
|
|
40
|
+
`__session` cookie and is resolved everywhere except `/media`, `/embed` and
|
|
41
|
+
`/api/v1`. `role` is their ACMS role. `can('content.edit')` checks a permission from
|
|
42
|
+
Team → Roles; unknown permission keys return `false`. A session from
|
|
43
|
+
`ADMIN_PASSWORD` has `id: '__owner_env__'`, role `owner` and an empty `email`.
|
|
44
|
+
A page that reads `currentUser` is personal, so don't let a shared cache keep it. A
|
|
45
|
+
request with a session cookie is never cached publicly.
|
|
46
|
+
- **`listEvents(repo, opts)`**: events by first start date, earliest first. By default,
|
|
47
|
+
every status except archived. `publishedOnly: true` returns only published events.
|
|
48
|
+
`calendar` (an id or slug) limits the list to one calendar. Values are the live ones;
|
|
49
|
+
unpublished edits staged in the admin are not merged in.
|
|
50
|
+
- **`getEvent(repo, id)`**: one event, or `null`.
|
|
51
|
+
- **`listOccurrences(repo, { eventId?, from, to })`**: occurrences in `[from, to)`,
|
|
52
|
+
soonest first, for one event or all non-archived events. The window can be up to 400
|
|
53
|
+
days. Each occurrence has its `key` (below) and `cancelled`, which is true when the
|
|
54
|
+
whole event or just that date is cancelled. Drafts are included, so check
|
|
55
|
+
`o.event.status` before showing anything publicly.
|
|
56
|
+
- **`cancelOccurrence(repo, eventId, key, { url, locals })`**: cancels one date of a
|
|
57
|
+
repeating event. The change applies immediately, even on a published event, and
|
|
58
|
+
leaves any unpublished admin edits on that event as they are. That date stays listed,
|
|
59
|
+
struck through as "Cancelled". The room it holds becomes bookable for that slot. The
|
|
60
|
+
event page, `/api/v1/events`, `/api/calendar/events`, the app and the ICS feeds show
|
|
61
|
+
it as cancelled, and the `event.updated` webhook fires. Calling it again for a date
|
|
62
|
+
that's already cancelled does nothing.
|
|
63
|
+
- **`restoreOccurrence(...)`**: undoes `cancelOccurrence` for that date. Also
|
|
64
|
+
idempotent.
|
|
65
|
+
|
|
66
|
+
Both write functions throw an `Error` with a `status` and a message that is safe to show
|
|
67
|
+
people:
|
|
68
|
+
|
|
69
|
+
| status | when |
|
|
70
|
+
|---|---|
|
|
71
|
+
| 404 | the event doesn't exist |
|
|
72
|
+
| 400 | the event doesn't repeat; the key isn't one of its dates (wrong format, not generated by the rule, skipped, or the series moved); the date has started or passed |
|
|
73
|
+
|
|
74
|
+
> **They do no authorization.** Anyone who can reach your code can cancel a date. Check
|
|
75
|
+
> who is calling (`currentUser`, your own signed link, etc.) before you call them.
|
|
76
|
+
|
|
77
|
+
## Occurrence keys
|
|
78
|
+
|
|
79
|
+
An occurrence key is the occurrence's start as **wall-clock time**, in the same format
|
|
80
|
+
as the event's `start` and `exdates`:
|
|
81
|
+
|
|
82
|
+
- timed events: `YYYY-MM-DDTHH:mm` in the event's own timezone (the site timezone when
|
|
83
|
+
the event has none), e.g. `2030-01-09T19:00`
|
|
84
|
+
- all-day events: `YYYY-MM-DD`, e.g. `2030-01-09`
|
|
85
|
+
|
|
86
|
+
Take keys from `listOccurrences` (`o.key`) rather than building them by hand. Cancelled
|
|
87
|
+
keys are stored on the event as `cancelledDates`. If the series is later moved to
|
|
88
|
+
another day (the admin's drag-to-move), its cancelled dates move with it. If its time
|
|
89
|
+
or rule is edited instead, keys that no longer match an occurrence are ignored, not an
|
|
90
|
+
error.
|
|
91
|
+
|
|
92
|
+
## Private event fields
|
|
93
|
+
|
|
94
|
+
A custom field can be private:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
acms({ calendar: { eventFields: [{ key: 'host_email', label: 'Host email', type: 'text', private: true }] } })
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Private values are saved and editable in the admin (marked "private" there) and
|
|
101
|
+
returned by this API (`event.fields.host_email`). They never appear in public output:
|
|
102
|
+
the event page and theme event templates, `/api/v1/events` and the app, the
|
|
103
|
+
`/api/calendar/events` API (API keys with `events:read` excepted), webhooks, ICS feeds,
|
|
104
|
+
search, or the events that theme helpers and sections receive.
|
|
105
|
+
|
|
106
|
+
## Example: let a class's host cancel one week
|
|
107
|
+
|
|
108
|
+
`src/actions/index.ts` in the site:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { defineAction, ActionError } from 'astro:actions';
|
|
112
|
+
import { z } from 'astro:schema';
|
|
113
|
+
import { server as acmsActions } from '@acms-subir/core/actions';
|
|
114
|
+
import { getRepo, currentUser, getEvent, cancelOccurrence } from '@acms-subir/core/server';
|
|
115
|
+
|
|
116
|
+
export const server = {
|
|
117
|
+
...acmsActions,
|
|
118
|
+
cancelClassDate: defineAction({
|
|
119
|
+
input: z.object({ eventId: z.string(), date: z.string() }),
|
|
120
|
+
handler: async ({ eventId, date }, ctx) => {
|
|
121
|
+
const repo = await getRepo(ctx.locals);
|
|
122
|
+
const event = await getEvent(repo, eventId);
|
|
123
|
+
const me = await currentUser(ctx);
|
|
124
|
+
// your rule: staff who can edit content, or the host named on the event
|
|
125
|
+
const allowed = me && (me.can('content.edit') || me.email === event?.fields.host_email);
|
|
126
|
+
if (!allowed) throw new ActionError({ code: 'FORBIDDEN', message: 'You can’t cancel this class.' });
|
|
127
|
+
try {
|
|
128
|
+
const updated = await cancelOccurrence(repo, eventId, date, { url: ctx.url, locals: ctx.locals });
|
|
129
|
+
return { cancelledDates: updated.cancelledDates };
|
|
130
|
+
} catch (e) {
|
|
131
|
+
const status = (e as { status?: number }).status;
|
|
132
|
+
throw new ActionError({ code: status === 404 ? 'NOT_FOUND' : 'BAD_REQUEST', message: (e as Error).message });
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Show the dates to choose from with `listOccurrences(repo, { eventId, from: Date.now(), to:
|
|
140
|
+
Date.now() + 90 * 86_400_000 })` and pass `o.key` as `date`.
|
|
141
|
+
|
|
142
|
+
Writes made in an action or another non-GET request also clear the site's shared cache
|
|
143
|
+
and send pending notifications once the request finishes. The core middleware does this
|
|
144
|
+
for every route, including yours.
|
package/integration.mjs
CHANGED
|
@@ -400,7 +400,9 @@ function resolveCalendar(calendar, contentBases, locales) {
|
|
|
400
400
|
}
|
|
401
401
|
const label = f.label ?? key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ');
|
|
402
402
|
if (typeof label !== 'string') throw new Error(`acms: calendar.eventFields "${key}".label must be a string`);
|
|
403
|
-
|
|
403
|
+
const isPrivate = f.private ?? false;
|
|
404
|
+
if (typeof isPrivate !== 'boolean') throw new Error(`acms: calendar.eventFields "${key}".private must be true or false`);
|
|
405
|
+
resolved.eventFields.push({ key, label, type, options, private: isPrivate });
|
|
404
406
|
}
|
|
405
407
|
if (resolved.eventFields.length > 30) throw new Error('acms: calendar.eventFields allows at most 30 fields');
|
|
406
408
|
return resolved;
|
|
@@ -505,13 +507,16 @@ function acmsVitePlugin(userConfig) {
|
|
|
505
507
|
* search (/api/v1/search and /api/v1/content?q=), and
|
|
506
508
|
* the header search modal. On by default — set `enabled: false` to remove
|
|
507
509
|
* all three.
|
|
508
|
-
* @param {false | {enabled?: boolean, base?: string, eventsBase?: string, defaultView?: 'month' | 'week' | 'list', eventFields?: Array<{key: string, label?: string, type?: 'text' | 'textarea' | 'number' | 'url' | 'select', options?: string[]}>}} [options.calendar]
|
|
510
|
+
* @param {false | {enabled?: boolean, base?: string, eventsBase?: string, defaultView?: 'month' | 'week' | 'list', eventFields?: Array<{key: string, label?: string, type?: 'text' | 'textarea' | 'number' | 'url' | 'select', options?: string[], private?: boolean}>}} [options.calendar]
|
|
509
511
|
* Calendar module: the main calendar page at `base` (default '/calendar')
|
|
510
512
|
* with subscribe feeds (`<base>.ics`, `<base>/<slug>.ics`), event pages at
|
|
511
513
|
* `eventsBase` (default '/events'), a public JSON API under /api/calendar,
|
|
512
514
|
* embeds, and the admin Calendar section. `eventFields` adds custom fields
|
|
513
515
|
* to every event (edited in the event sidebar, exposed to templates and the
|
|
514
|
-
* API).
|
|
516
|
+
* API). `private: true` keeps a field's values out of every public output
|
|
517
|
+
* (event page, APIs, webhooks, app, feeds); site code reads them through
|
|
518
|
+
* @acms-subir/core/server. On by default — `calendar: false` removes all
|
|
519
|
+
* of it.
|
|
515
520
|
* @param {{provider?: 'geoapify' | 'google' | 'mapbox' | 'locationiq' | 'stadia' | 'photon' | 'nominatim' | 'pelias'}} [options.maps]
|
|
516
521
|
* Maps for events (bring your own provider): address suggestions in the
|
|
517
522
|
* event editor and a static map image on event pages. Set MAPS_API_KEY
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acms-subir/core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
".": "./integration.mjs",
|
|
32
32
|
"./package.json": "./package.json",
|
|
33
33
|
"./actions": "./src/actions/index.ts",
|
|
34
|
+
"./server": "./src/server/index.ts",
|
|
34
35
|
"./theme": "./src/theme-sdk/index.ts",
|
|
35
36
|
"./theme/schema": "./src/theme-sdk/contract.mjs",
|
|
36
37
|
"./theme/components/LiveCard.astro": "./src/components/LiveCard.astro",
|
package/src/actions/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { loadFeed } from '../lib/calendar/ics';
|
|
|
29
29
|
import { NATIVE_ALL } from '../lib/calendar/render';
|
|
30
30
|
import { collectViewEvents } from '../lib/calendar/data';
|
|
31
31
|
import { loadBoard } from '../lib/calendar/board';
|
|
32
|
-
import { duplicateEvent, moveOccurrence, moveSeries, quickCreate, undoMove } from '../lib/calendar/schedule';
|
|
32
|
+
import { cancelOccurrence, duplicateEvent, moveOccurrence, moveSeries, quickCreate, restoreOccurrence, undoMove } from '../lib/calendar/schedule';
|
|
33
33
|
import { waitUntilFrom } from '../lib/webhooks';
|
|
34
34
|
import { eventChanged, eventDeleted } from '../lib/calendar/hooks';
|
|
35
35
|
import { siteOrigin } from '../lib/seo';
|
|
@@ -1147,6 +1147,18 @@ export const server = {
|
|
|
1147
1147
|
try { await undoMove(repo, undo, await publishHooks(ctx)); return { ok: true }; } catch (e) { asActionError(e); }
|
|
1148
1148
|
},
|
|
1149
1149
|
}),
|
|
1150
|
+
// Schedule: cancel (or restore) one date of a repeating event, at once
|
|
1151
|
+
cancelDate: defineAction({
|
|
1152
|
+
input: z.object({ id: z.string(), occurrence: z.string().max(20), restore: z.boolean().default(false) }),
|
|
1153
|
+
handler: async ({ id, occurrence, restore }, ctx) => {
|
|
1154
|
+
await requireCan(ctx, 'content.edit');
|
|
1155
|
+
const repo = await getRepo(ctx.locals);
|
|
1156
|
+
try {
|
|
1157
|
+
const e = await (restore ? restoreOccurrence : cancelOccurrence)(repo, id, occurrence, await publishHooks(ctx));
|
|
1158
|
+
return { id: e.id, cancelledDates: e.cancelledDates };
|
|
1159
|
+
} catch (e) { asActionError(e); }
|
|
1160
|
+
},
|
|
1161
|
+
}),
|
|
1150
1162
|
duplicate: defineAction({
|
|
1151
1163
|
input: z.object({ id: z.string() }),
|
|
1152
1164
|
handler: async ({ id }, ctx) => {
|
|
@@ -18,13 +18,15 @@ interface Props {
|
|
|
18
18
|
settings: Settings;
|
|
19
19
|
calendars: Calendar[];
|
|
20
20
|
html: string;
|
|
21
|
-
upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[];
|
|
21
|
+
upcoming: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean })[];
|
|
22
22
|
origin: string;
|
|
23
23
|
preview?: boolean;
|
|
24
24
|
// RSVP state when the event takes RSVPs (null otherwise)
|
|
25
25
|
rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
|
|
26
26
|
}
|
|
27
27
|
const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
|
|
28
|
+
// the whole event, or just this date of a repeating one
|
|
29
|
+
const cancelled = e.cancelled || Boolean(o.cancelled);
|
|
28
30
|
const image = eventImage(e, settings);
|
|
29
31
|
const tz = eventTimeZone(e, settings);
|
|
30
32
|
const locale = siteLocale(settings);
|
|
@@ -47,7 +49,7 @@ const showMap = loc.showMap !== false && Boolean(loc.mapImage) && typeof loc.lat
|
|
|
47
49
|
const directions = showMap ? safeHref(directionsUrl(loc)) : '';
|
|
48
50
|
const mapCredit = showMap ? mapAttribution(loc.mapImage) : '';
|
|
49
51
|
const mapLabel = loc.name || loc.address || 'the location';
|
|
50
|
-
const fieldRows = CALENDAR.eventFields.filter((fd) => e.fields[fd.key]).map((fd) => ({ label: fd.label, value: e.fields[fd.key], type: fd.type }));
|
|
52
|
+
const fieldRows = CALENDAR.eventFields.filter((fd) => !fd.private && e.fields[fd.key]).map((fd) => ({ label: fd.label, value: e.fields[fd.key], type: fd.type }));
|
|
51
53
|
const visibleTags = e.tags.filter((t) => !t.startsWith('#'));
|
|
52
54
|
const I = {
|
|
53
55
|
pin: 'M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
|
|
@@ -61,7 +63,7 @@ const I = {
|
|
|
61
63
|
const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(e.location.url);
|
|
62
64
|
---
|
|
63
65
|
|
|
64
|
-
<article class={`pc-ev-page${
|
|
66
|
+
<article class={`pc-ev-page${cancelled ? ' is-cancelled' : ''}`}>
|
|
65
67
|
{preview && <p class="pc-ev-preview">Preview — only you can see this {e.status === 'published' ? 'version' : 'draft'}.</p>}
|
|
66
68
|
{image.src && (
|
|
67
69
|
<figure class="pc-ev-hero"><img src={image.src} alt={image.alt} /></figure>
|
|
@@ -78,6 +80,7 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
78
80
|
)}
|
|
79
81
|
<h1 class="pc-ev-title">{e.title}</h1>
|
|
80
82
|
{e.cancelled && <p class="pc-ev-cancelled"><b>This event is cancelled.</b> It stays listed so nobody is left wondering.</p>}
|
|
83
|
+
{!e.cancelled && cancelled && <p class="pc-ev-cancelled"><b>Cancelled on {f.date}.</b> Other dates go ahead as usual.</p>}
|
|
81
84
|
{e.excerpt && <p class="pc-ev-lede">{e.excerpt}</p>}
|
|
82
85
|
<div class="pc-ev-body post-content" set:html={html} />
|
|
83
86
|
</div>
|
|
@@ -149,7 +152,7 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
149
152
|
</details>
|
|
150
153
|
) : <p class="pc-ev-full">{rsvp.open ? 'This event is full.' : 'RSVPs are closed.'}</p>}
|
|
151
154
|
</div>
|
|
152
|
-
) : e.cta.label && ctaHref && !
|
|
155
|
+
) : e.cta.label && ctaHref && !cancelled && (
|
|
153
156
|
<a class={`pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}`} href={ctaHref}>{e.cta.label}</a>
|
|
154
157
|
)}
|
|
155
158
|
<details class="pc-ev-add">
|
|
@@ -180,7 +180,7 @@ export default function CalendarBoard(props: Props) {
|
|
|
180
180
|
setDraft({
|
|
181
181
|
key: '__draft', kind: 'event', id: '', title: d.title, startDay: d.day, endDay: d.endDay, allDay: d.allDay || d.endDay > d.day,
|
|
182
182
|
startMin: d.startMin, endMin: d.endMin, time: d.allDay ? '' : shortTime(d.startMin), range: '', color, calendarId: d.calendarId,
|
|
183
|
-
calendarName: '', status: 'draft', recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, location: '', edited: false, url: '',
|
|
183
|
+
calendarName: '', status: 'draft', recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, dateCancelled: false, location: '', edited: false, url: '',
|
|
184
184
|
});
|
|
185
185
|
};
|
|
186
186
|
|
|
@@ -235,6 +235,13 @@ export default function CalendarBoard(props: Props) {
|
|
|
235
235
|
if (r.error || !r.data) { setToast({ text: r.error?.message ?? 'Couldn’t duplicate.', error: true }); return; }
|
|
236
236
|
location.href = `/admin/calendar/events/${r.data.id}`;
|
|
237
237
|
}
|
|
238
|
+
async function cancelDate(it: BoardItem, restore: boolean) {
|
|
239
|
+
const r = await actions.events.cancelDate({ id: it.id, occurrence: it.occurrence, restore });
|
|
240
|
+
setPeek(null);
|
|
241
|
+
if (r.error) { setToast({ text: r.error.message, error: true }); return; }
|
|
242
|
+
setToast({ text: `${restore ? 'Restored' : 'Cancelled'} “${it.title}” on ${dayLabel(it.startDay)}` });
|
|
243
|
+
await refresh();
|
|
244
|
+
}
|
|
238
245
|
async function remove(it: BoardItem) {
|
|
239
246
|
if (!confirm(it.recurring ? `Delete “${it.title}” and every repeat?` : `Delete “${it.title}”?`)) return;
|
|
240
247
|
const r = await actions.events.delete({ id: it.id });
|
|
@@ -470,12 +477,14 @@ export default function CalendarBoard(props: Props) {
|
|
|
470
477
|
{it.rooms.length > 0 && <li>{it.roomsHeld ? 'Holds' : 'Will hold once published:'} {it.rooms.join(', ')}</li>}
|
|
471
478
|
{statusWord(it) && <li>{statusWord(it)}{it.edited ? ' · has unpublished changes' : ''}</li>}
|
|
472
479
|
{!statusWord(it) && it.edited && <li>Has unpublished changes</li>}
|
|
473
|
-
{it.cancelled && <li>Cancelled</li>}
|
|
480
|
+
{it.cancelled && <li>{it.dateCancelled ? 'This date is cancelled' : 'Cancelled'}</li>}
|
|
474
481
|
</ul>
|
|
475
482
|
<div className="sch-peek-actions">
|
|
476
483
|
{it.kind === 'event' && <>
|
|
477
484
|
<a className="btn sm primary" href={`/admin/calendar/events/${it.id}`}>Edit</a>
|
|
478
485
|
<button type="button" className="btn sm" onClick={() => duplicate(it)}>Duplicate</button>
|
|
486
|
+
{it.recurring && it.dateCancelled && <button type="button" className="btn sm" onClick={() => cancelDate(it, true)}>Restore this date</button>}
|
|
487
|
+
{it.recurring && !it.cancelled && <button type="button" className="btn sm" onClick={() => cancelDate(it, false)}>Cancel this date</button>}
|
|
479
488
|
{it.url && <a className="btn sm ghost" href={it.url} target="_blank" rel="noopener">View ↗</a>}
|
|
480
489
|
<button type="button" className="btn sm ghost danger" onClick={() => remove(it)}>Delete</button>
|
|
481
490
|
</>}
|
|
@@ -53,7 +53,7 @@ export function OrganizeCard({ meta, onChange, calendars, fields, tags }: Props)
|
|
|
53
53
|
const set = (v: string) => onChange({ fields: { ...meta.fields, [f.key]: v } });
|
|
54
54
|
return (
|
|
55
55
|
<label key={f.key} className="pe-field">
|
|
56
|
-
<span>{f.label}</span>
|
|
56
|
+
<span>{f.label}{f.private && <small title="Saved with the event but never shown on the site, in feeds or in the app"> · private</small>}</span>
|
|
57
57
|
{f.type === 'select' ? (
|
|
58
58
|
<select value={value} onChange={(e) => set(e.target.value)}>
|
|
59
59
|
<option value="">—</option>
|
package/src/lib/booking/holds.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Rooms held by events. An event that lists rooms/people in roomHold makes
|
|
2
2
|
// them unbookable while it happens (± setup/cleanup) or for the whole day.
|
|
3
3
|
// Holds are computed from events every time — never stored as bookings — so
|
|
4
|
-
// moving, repeating, skipping a date, cancelling or
|
|
5
|
-
// or moves the room automatically. availabilityContext() adds these to its
|
|
4
|
+
// moving, repeating, skipping a date, cancelling the event or one of its
|
|
5
|
+
// dates, or deleting it frees or moves the room automatically. availabilityContext() adds these to its
|
|
6
6
|
// busy list, so every booking path (pages, embeds, app, API, agent) obeys.
|
|
7
7
|
import type { ContentRepository } from '../repo/types';
|
|
8
8
|
import type { Booking, CalEvent, Resource, Settings } from '../schema';
|
|
@@ -55,6 +55,8 @@ export function holdsFromEvents(
|
|
|
55
55
|
const pad = 2 * DAY;
|
|
56
56
|
const occurrences = expandEvents(holding, settings, { displayTz: settings.timezone || 'UTC', now: from, windowStartMs: from - pad, windowEndMs: to + pad });
|
|
57
57
|
for (const o of occurrences) {
|
|
58
|
+
// a cancelled date frees the room for that slot only
|
|
59
|
+
if (o.cancelled) continue;
|
|
58
60
|
const h = o.event.roomHold;
|
|
59
61
|
for (const resourceId of h.resourceIds) {
|
|
60
62
|
if (opts.resourceId && resourceId !== opts.resourceId) continue;
|
|
@@ -10,7 +10,7 @@ import { emitWebhook } from '../webhooks';
|
|
|
10
10
|
import { parseFrom } from '../email/config';
|
|
11
11
|
import { kickEmail } from '../email/service';
|
|
12
12
|
import { manageUrl as manageUrlFor, eventUrl } from '../calendar/config';
|
|
13
|
-
import { eventTimeZone, locationLine } from '../calendar/events';
|
|
13
|
+
import { eventTimeZone, locationLine, occurrenceKey } from '../calendar/events';
|
|
14
14
|
import { bookingEmail, whenText, type BookingEmail } from './emails';
|
|
15
15
|
import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
|
|
16
16
|
import { decideStatus, linkApproval } from './approval';
|
|
@@ -267,6 +267,9 @@ export async function createRsvp(
|
|
|
267
267
|
if (!e.rsvp.enabled) throw new BookingError(400, 'This event doesn’t take RSVPs.');
|
|
268
268
|
if (e.cancelled) throw new BookingError(410, 'This event is cancelled.');
|
|
269
269
|
if (args.end <= (opts.now ?? Date.now())) throw new BookingError(410, 'This event has already happened.');
|
|
270
|
+
if (e.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, args.start, await repo.getSettings()))) {
|
|
271
|
+
throw new BookingError(410, 'This date is cancelled.');
|
|
272
|
+
}
|
|
270
273
|
const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
|
|
271
274
|
const booker = validateBooker(null, args.booker);
|
|
272
275
|
await checkEmailCap(repo, booker.email, args.source, await repo.getSettings(), opts.now);
|
|
@@ -110,7 +110,7 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
|
|
|
110
110
|
case 'get_event': {
|
|
111
111
|
const e = await repo.getEvent(input.arguments.id);
|
|
112
112
|
if (!e) throw new ApiError(404, 'Event not found.');
|
|
113
|
-
return { ...eventToJson(mergeEventDraft(e), ctx.origin, calMap, settings.timezone), bodyHtml: mergeEventDraft(e).bodyHtml, hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id) };
|
|
113
|
+
return { ...eventToJson(mergeEventDraft(e), ctx.origin, calMap, settings.timezone, { includePrivate: true }), bodyHtml: mergeEventDraft(e).bodyHtml, hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id) };
|
|
114
114
|
}
|
|
115
115
|
case 'create_event': {
|
|
116
116
|
const a = input.arguments;
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// The public JSON shape of an event — one definition shared by the REST API,
|
|
2
2
|
// webhook payloads and agent tools, so integrations see the same fields
|
|
3
|
-
// everywhere. Drafts, internal ids of private calendars
|
|
4
|
-
// never appear
|
|
3
|
+
// everywhere. Drafts, internal ids of private calendars, staging data and
|
|
4
|
+
// private custom fields (unless a staff caller asks for them) never appear
|
|
5
|
+
// here.
|
|
5
6
|
import type { CalEvent, Calendar } from '../schema';
|
|
6
|
-
import { calendarFeedUrl, eventIcsUrl, eventUrl } from './config';
|
|
7
|
+
import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
|
|
7
8
|
import { describeRepeat } from './rrule';
|
|
8
9
|
|
|
9
10
|
export interface EventJson {
|
|
@@ -23,6 +24,7 @@ export interface EventJson {
|
|
|
23
24
|
rrule: string;
|
|
24
25
|
repeats: string;
|
|
25
26
|
exdates: string[];
|
|
27
|
+
cancelledDates: string[];
|
|
26
28
|
location: CalEvent['location'];
|
|
27
29
|
cta: CalEvent['cta'];
|
|
28
30
|
rsvp?: CalEvent['rsvp'];
|
|
@@ -36,7 +38,7 @@ export interface EventJson {
|
|
|
36
38
|
|
|
37
39
|
const abs = (origin: string, path: string) => (/^https?:\/\//.test(path) ? path : origin + path);
|
|
38
40
|
|
|
39
|
-
export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = ''): EventJson {
|
|
41
|
+
export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean } = {}): EventJson {
|
|
40
42
|
return {
|
|
41
43
|
id: e.id,
|
|
42
44
|
slug: e.slug,
|
|
@@ -54,6 +56,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
54
56
|
rrule: e.rrule,
|
|
55
57
|
repeats: describeRepeat(e.rrule, e.start),
|
|
56
58
|
exdates: e.exdates,
|
|
59
|
+
cancelledDates: e.cancelledDates ?? [],
|
|
57
60
|
location: e.location,
|
|
58
61
|
cta: e.cta,
|
|
59
62
|
rsvp: e.rsvp,
|
|
@@ -64,7 +67,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
64
67
|
.map((id) => calendars.get(id))
|
|
65
68
|
.filter((c): c is Calendar => Boolean(c) && c!.visibility !== 'private')
|
|
66
69
|
.map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
|
|
67
|
-
fields: e.fields,
|
|
70
|
+
fields: opts.includePrivate ? e.fields : publicEventFields(e.fields),
|
|
68
71
|
updatedAt: new Date(e.updatedAt).toISOString(),
|
|
69
72
|
};
|
|
70
73
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { ContentRepository } from '../repo/types';
|
|
6
6
|
import type { CalEvent, Settings } from '../schema';
|
|
7
7
|
import { collectViewEvents } from './data';
|
|
8
|
-
import {
|
|
8
|
+
import { occurrenceKey, safeColor } from './events';
|
|
9
9
|
import { tzParts, zonedToUtc } from './ics';
|
|
10
10
|
import { addDays, isIsoDay, type GridItem, type Iso } from './grid';
|
|
11
11
|
import { bookingsEnabled } from './config';
|
|
@@ -32,6 +32,8 @@ export interface BoardItem extends GridItem {
|
|
|
32
32
|
// this occurrence's start as wall time in the event's own zone (for exdates)
|
|
33
33
|
occurrence: string;
|
|
34
34
|
cancelled: boolean;
|
|
35
|
+
// just this date is cancelled (Restore brings it back)
|
|
36
|
+
dateCancelled: boolean;
|
|
35
37
|
location: string;
|
|
36
38
|
edited: boolean; // live event with unpublished changes
|
|
37
39
|
url: string; // public URL ('' when none)
|
|
@@ -102,11 +104,7 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
|
|
|
102
104
|
const startDay = s.day;
|
|
103
105
|
const endDay = allDay ? addDays(startDay, Math.max(1, v.days) - 1) : en.day < s.day ? s.day : en.day;
|
|
104
106
|
const cal = e ? e.calendarIds.map((id) => calById.get(id)).find(Boolean) : calendars.find((c) => c.name === v.calendarName && c.kind === 'feed');
|
|
105
|
-
|
|
106
|
-
if (e) {
|
|
107
|
-
const p = tzParts(v.start, eventTimeZone(e, settings));
|
|
108
|
-
occurrence = allDay ? isoOf(p) : `${isoOf(p)}T${pad(p.hh)}:${pad(p.mm)}`;
|
|
109
|
-
}
|
|
107
|
+
const occurrence = e ? occurrenceKey(e, v.start, settings, tz) : '';
|
|
110
108
|
items.push({
|
|
111
109
|
key: e ? `e:${e.id}:${v.start}` : `f:${cal?.id ?? ''}:${v.start}:${v.title}`,
|
|
112
110
|
kind: e ? 'event' : 'feed',
|
|
@@ -127,6 +125,7 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
|
|
|
127
125
|
roomsHeld: e ? holdsRooms(e) : false,
|
|
128
126
|
occurrence,
|
|
129
127
|
cancelled: Boolean(v.cancelled),
|
|
128
|
+
dateCancelled: Boolean(e && !e.cancelled && (e.cancelledDates ?? []).includes(occurrence)),
|
|
130
129
|
location: v.location,
|
|
131
130
|
edited: Boolean(e?.draft),
|
|
132
131
|
url: e?.status === 'published' ? v.url ?? '' : '',
|
|
@@ -157,7 +156,7 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
|
|
|
157
156
|
calendarId: `res:${b.resourceId}`,
|
|
158
157
|
calendarName: r?.name ?? '',
|
|
159
158
|
status: b.status as BoardStatus,
|
|
160
|
-
recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false,
|
|
159
|
+
recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, dateCancelled: false,
|
|
161
160
|
location: r?.name ?? '', edited: false, url: '',
|
|
162
161
|
});
|
|
163
162
|
}
|
|
@@ -26,11 +26,13 @@ const esc = (s: string) =>
|
|
|
26
26
|
export const safeHref = (h: string) => (/^(https?:\/\/|\/(?!\/)|mailto:|tel:|#)/i.test(h.trim()) ? h.trim() : '');
|
|
27
27
|
|
|
28
28
|
export function eventCardHtml(
|
|
29
|
-
o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'
|
|
29
|
+
o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean },
|
|
30
30
|
settings: Settings,
|
|
31
31
|
opts: { showImage: boolean; showCta?: boolean; href?: string; heading?: 'h2' | 'h3' }
|
|
32
32
|
): string {
|
|
33
33
|
const e: CalEvent = o.event;
|
|
34
|
+
// the whole event, or just this date
|
|
35
|
+
const cancelled = e.cancelled || Boolean(o.cancelled);
|
|
34
36
|
const tz = settings.timezone || 'UTC';
|
|
35
37
|
const f = formatOccurrence(o, tz, siteLocale(settings));
|
|
36
38
|
const href = opts.href ?? eventUrl(e.slug);
|
|
@@ -46,12 +48,12 @@ export function eventCardHtml(
|
|
|
46
48
|
const loc = locationLine(e.location);
|
|
47
49
|
const ctaHref = safeHref(e.cta.href);
|
|
48
50
|
const cta =
|
|
49
|
-
opts.showCta && e.cta.label && ctaHref && !
|
|
51
|
+
opts.showCta && e.cta.label && ctaHref && !cancelled
|
|
50
52
|
? `<a class="pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}" href="${esc(ctaHref)}">${esc(e.cta.label)}</a>`
|
|
51
53
|
: '';
|
|
52
54
|
return (
|
|
53
|
-
`<article class="pc-ev-card${
|
|
54
|
-
`<div class="pc-ev-card-when">${
|
|
55
|
+
`<article class="pc-ev-card${cancelled ? ' is-cancelled' : ''}">${media}<div class="pc-ev-card-body">` +
|
|
56
|
+
`<div class="pc-ev-card-when">${cancelled ? '<span class="pc-ev-flag">Cancelled</span> ' : ''}${esc(when)}</div>` +
|
|
55
57
|
`<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || 'Untitled event')}</a></${h}>` +
|
|
56
58
|
(e.excerpt ? `<p class="pc-ev-card-excerpt">${esc(e.excerpt)}</p>` : '') +
|
|
57
59
|
(loc ? `<div class="pc-ev-card-loc">${esc(loc)}</div>` : '') +
|
|
@@ -72,11 +74,11 @@ export async function upcomingEvents(
|
|
|
72
74
|
displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
|
|
73
75
|
}, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false });
|
|
74
76
|
const seen = new Set<string>();
|
|
75
|
-
const out: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[] = [];
|
|
77
|
+
const out: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>[] = [];
|
|
76
78
|
for (const v of events) {
|
|
77
79
|
if (!v.event || v.end <= now || seen.has(v.event.id)) continue;
|
|
78
80
|
seen.add(v.event.id);
|
|
79
|
-
out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days });
|
|
81
|
+
out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled) });
|
|
80
82
|
if (out.length >= opts.limit) break;
|
|
81
83
|
}
|
|
82
84
|
return out;
|
|
@@ -13,6 +13,9 @@ export interface EventFieldConfig {
|
|
|
13
13
|
label: string;
|
|
14
14
|
type: EventFieldType;
|
|
15
15
|
options: string[];
|
|
16
|
+
// stored and editable in the admin, returned by @acms-subir/core/server,
|
|
17
|
+
// but never in public output (event page, APIs, webhooks, app, feeds)
|
|
18
|
+
private: boolean;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export interface CalendarConfig {
|
|
@@ -43,6 +46,15 @@ const FALLBACK: CalendarConfig = {
|
|
|
43
46
|
|
|
44
47
|
export const CALENDAR: CalendarConfig = { ...FALLBACK, ...(userConfig.calendar ?? {}) };
|
|
45
48
|
|
|
49
|
+
/** only the event's values for configured, non-private fields */
|
|
50
|
+
export function publicEventFields(fields: Record<string, string>): Record<string, string> {
|
|
51
|
+
return Object.fromEntries(CALENDAR.eventFields.filter((f) => !f.private && Object.hasOwn(fields, f.key)).map((f) => [f.key, fields[f.key]]));
|
|
52
|
+
}
|
|
53
|
+
/** the event as visitors may see it: private field values removed */
|
|
54
|
+
export function withoutPrivateFields<T extends { fields: Record<string, string> }>(e: T): T {
|
|
55
|
+
return { ...e, fields: publicEventFields(e.fields) };
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
export const eventUrl = (slug: string) => `${CALENDAR.eventsBase}/${slug}`;
|
|
47
59
|
export const eventIcsUrl = (slug: string) => `${CALENDAR.eventsBase}/${slug}.ics`;
|
|
48
60
|
export const calendarPageUrl = (slug?: string) => (slug ? `${CALENDAR.base}?cal=${encodeURIComponent(slug)}` : CALENDAR.base);
|