@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/cli/main.mjs +3 -2
  3. package/cli/migrations.generated.mjs +12 -1
  4. package/docs/server.md +144 -0
  5. package/integration.mjs +8 -3
  6. package/package.json +2 -1
  7. package/src/actions/index.ts +13 -1
  8. package/src/components/EventPage.astro +7 -4
  9. package/src/components/calendar/CalendarBoard.tsx +11 -2
  10. package/src/components/editor/event/OrganizeCard.tsx +1 -1
  11. package/src/lib/booking/holds.ts +4 -2
  12. package/src/lib/booking/service.ts +4 -1
  13. package/src/lib/calendar/agent.ts +1 -1
  14. package/src/lib/calendar/api.ts +8 -5
  15. package/src/lib/calendar/board.ts +6 -7
  16. package/src/lib/calendar/cards.ts +8 -6
  17. package/src/lib/calendar/config.ts +12 -0
  18. package/src/lib/calendar/data.ts +10 -3
  19. package/src/lib/calendar/events.ts +35 -2
  20. package/src/lib/calendar/ics-write.ts +27 -3
  21. package/src/lib/calendar/schedule.ts +45 -4
  22. package/src/lib/calendar/seo.ts +2 -2
  23. package/src/lib/content-service.ts +4 -2
  24. package/src/lib/mobile/calendar.ts +4 -3
  25. package/src/lib/mobile/schemas.ts +3 -3
  26. package/src/lib/mobile/search.ts +1 -1
  27. package/src/lib/repo/migrations.ts +12 -1
  28. package/src/lib/repo/sql.ts +13 -8
  29. package/src/lib/repo/types.ts +3 -1
  30. package/src/lib/schema.ts +4 -0
  31. package/src/lib/sections.ts +2 -2
  32. package/src/lib/sync/catalog.ts +1 -0
  33. package/src/pages/api/calendar/docs.ts +1 -1
  34. package/src/pages/api/calendar/events/[id].ts +1 -1
  35. package/src/pages/api/calendar/events/index.ts +6 -4
  36. package/src/pages/calendar/index.astro +1 -1
  37. package/src/pages/events/[slug].astro +4 -3
  38. package/src/server/index.ts +141 -0
  39. package/src/theme-sdk/calendar.ts +6 -2
  40. package/src/virtual.d.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.20
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+
9
+ 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.
10
+
11
+ 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.
12
+
13
+ Client types add `cancelled` to event occurrences and `cancelledDates` to events.
14
+
15
+ 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.
16
+
3
17
  ## 0.3.19
4
18
 
5
19
  ### 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 = { ...CONTENT_VERSION_COLUMNS, settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale"] };
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
- resolved.eventFields.push({ key, label, type, options });
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). On by default — `calendar: false` removes all of it.
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.19",
3
+ "version": "0.3.20",
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",
@@ -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${e.cancelled ? ' is-cancelled' : ''}`}>
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 && !e.cancelled && (
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>
@@ -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 deleting the event frees
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;
@@ -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 and staging data
4
- // never appear here.
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 { eventTimeZone, safeColor } from './events';
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
- let occurrence = '';
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 && !e.cancelled
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${e.cancelled ? ' is-cancelled' : ''}">${media}<div class="pc-ev-card-body">` +
54
- `<div class="pc-ev-card-when">${e.cancelled ? '<span class="pc-ev-flag">Cancelled</span> ' : ''}${esc(when)}</div>` +
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);