domma-cms 1.6.59 → 1.7.0

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 (143) hide show
  1. package/CHANGELOG.md +4344 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +2 -2
  7. package/admin/js/lib/draft-owner.js +1 -0
  8. package/admin/js/lib/effect-defs.js +1 -1
  9. package/admin/js/lib/effects-builder.js +2 -2
  10. package/admin/js/lib/page-editor-reference.js +5 -5
  11. package/admin/js/lib/shortcode-tags.js +1 -1
  12. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  13. package/admin/js/views/user-editor.js +1 -1
  14. package/docs/DommaFeatures.md +3005 -0
  15. package/docs/MultiTenant.md +176 -0
  16. package/docs/api-reference.md +1667 -0
  17. package/docs/cache.md +96 -0
  18. package/docs/configuration.md +1811 -0
  19. package/docs/editions-and-licences.md +190 -0
  20. package/docs/getting-started.md +201 -0
  21. package/docs/guides/form-triggers.md +260 -0
  22. package/docs/guides/mongodb-storage-adapter.md +282 -0
  23. package/docs/guides/multi-page-forms.md +121 -0
  24. package/docs/images/scribe-composer.png +0 -0
  25. package/docs/markdown-shortcodes.md +1738 -0
  26. package/docs/pentest-report-2026-04-03.md +181 -0
  27. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  28. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  29. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  30. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  31. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  32. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  34. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  35. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  37. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  38. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  39. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  40. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  42. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  43. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  44. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  45. package/docs/plugin-development.md +827 -0
  46. package/docs/recruitment-recipe.md +501 -0
  47. package/docs/scaffolding.md +312 -0
  48. package/docs/screenshots/calculator-floating.png +0 -0
  49. package/docs/screenshots/calculator-standard.png +0 -0
  50. package/docs/screenshots/email-settings.png +0 -0
  51. package/docs/screenshots/messages-count.png +0 -0
  52. package/docs/structure/user-types.md +107 -0
  53. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  54. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  55. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  56. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  58. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  59. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  60. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  61. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  62. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  63. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  64. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  65. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  66. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  67. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  68. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  69. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  70. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  71. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  72. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  73. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  74. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  75. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  76. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  77. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  78. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  79. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  80. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  81. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  82. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  83. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  84. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  85. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  86. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  87. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  89. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  90. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  91. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  92. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  93. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  95. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  96. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  97. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  98. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  99. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  100. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  101. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  102. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  103. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  106. package/docs/theming.md +320 -0
  107. package/package.json +2 -2
  108. package/plugins/_lib/admin/mail/keyboard.js +49 -0
  109. package/plugins/_lib/admin/mail/reader-view.js +4 -2
  110. package/public/js/celebrations/core/canvas.js +1 -1
  111. package/public/js/celebrations/index.js +1 -1
  112. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  113. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  114. package/public/js/celebrations/message-attrs.mjs +1 -0
  115. package/public/js/celebrations/themes/anniversary.js +1 -0
  116. package/public/js/celebrations/themes/autumn.js +1 -0
  117. package/public/js/celebrations/themes/birthday.js +1 -0
  118. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  119. package/public/js/celebrations/themes/calendar.js +1 -0
  120. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  121. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  122. package/public/js/celebrations/themes/christmas.js +1 -1
  123. package/public/js/celebrations/themes/easter.js +1 -0
  124. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  125. package/public/js/celebrations/themes/garden.js +1 -0
  126. package/public/js/celebrations/themes/graduation.js +1 -0
  127. package/public/js/celebrations/themes/greeting.js +1 -0
  128. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  129. package/public/js/celebrations/themes/money-rain.js +1 -0
  130. package/public/js/celebrations/themes/new-year.js +1 -0
  131. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  132. package/public/js/celebrations/themes/occasions.js +1 -0
  133. package/public/js/celebrations/themes/rain-control.js +1 -0
  134. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  135. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  136. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  137. package/public/js/celebrations/themes/summer-garden.js +1 -0
  138. package/public/js/celebrations/themes/winter.js +1 -0
  139. package/public/js/celebrations/trait-data.mjs +1 -1
  140. package/public/js/effects.js +1 -1
  141. package/scripts/build.js +4 -0
  142. package/server/services/markdown.js +5 -2
  143. package/server/templates/page.html +1 -1
@@ -0,0 +1,538 @@
1
+ # Calendar Plugin - Design Spec
2
+
3
+ **Date:** 2026-04-07
4
+ **Status:** Approved
5
+
6
+ ---
7
+
8
+ ## Context
9
+
10
+ Domma CMS ships with a plugin architecture where bundled plugins register their collections, roles, and routes at enable-time. The Calendar plugin is a standalone plugin with two purposes:
11
+
12
+ 1. **Public-facing events calendar** - a public page at `/calendar` with an iCal feed and RSVP registration.
13
+ 2. **Composable scheduling service** - exports a `service.js` that other plugins (primarily Jobs) import directly to create and manage calendar occurrences without HTTP round-trips.
14
+
15
+ It is the first plugin to export a **service layer** (`service.js`) for cross-plugin consumption, establishing a pattern for tight inter-plugin integration without circular HTTP dependencies.
16
+
17
+ ---
18
+
19
+ ## Decisions
20
+
21
+ | Concern | Decision |
22
+ |---|---|
23
+ | Recurrence storage | Materialised occurrences - pre-generated for a rolling 12-month window |
24
+ | Jobs integration | Jobs imports `service.js` directly - no HTTP round-trip |
25
+ | Public routing | `plugin.public.js` handles `/feed.ics`, `/event/:slug`, `POST /rsvp`; index page is a seeded CMS page |
26
+ | RSVP | Seeded CMS form (`calendar-rsvp`); public event page embeds via `[form slug="calendar-rsvp"]` shortcode |
27
+ | Admin views | Three modes (Grid, List, Split) - all theme-aware, switchable per user via `S.set()` |
28
+ | Roles | Plugin-registered: `calendar-organiser` (level 2), `calendar-admin` (level 1) |
29
+ | iCal feed | Public events only; registered before `/:slug` to avoid parametric shadowing |
30
+ | Interview events | Created by Jobs via `service.js`; shown read-only in Calendar admin |
31
+ | Seeded assets | Page, form, views, block - all seeded on enable, removed on disable via `teardownPlugin` |
32
+ | Shortcode | `[calendar /]` registered on enable - renders upcoming public events; embeddable anywhere |
33
+
34
+ ---
35
+
36
+ ## Seeded Assets
37
+
38
+ The Calendar plugin seeds the following CMS assets on enable and removes them on disable via `teardownPlugin`. All assets are stored under `plugins/calendar/` and copied into the appropriate `content/` directories by `setupPlugin`.
39
+
40
+ ### Mechanism: `setupPlugin` / `teardownPlugin`
41
+
42
+ `setupPlugin(pluginName, services)` is called from `server/services/plugins.js:registerPlugins()` on every server startup (idempotent). For Calendar it:
43
+
44
+ 1. Seeds the three collections via `services.collections.createCollection` (existing behaviour - skipped if already present)
45
+ 2. Copies `plugins/calendar/forms/calendar-rsvp.json` → `content/forms/calendar-rsvp.json` (skipped if already present)
46
+ 3. Copies `plugins/calendar/pages/calendar.md` → `content/pages/calendar.md` (skipped if already present)
47
+ 4. Copies `plugins/calendar/blocks/upcoming-events.html` → `content/blocks/upcoming-events.html` (skipped if already present)
48
+ 5. Seeds three saved view JSON files into `content/views/` (skipped if already present; format: `{ slug, label, collection, filter, sort }`)
49
+
50
+ `teardownPlugin(pluginName)` is called when the plugin is disabled. For Calendar it removes:
51
+ - `content/forms/calendar-rsvp.json`
52
+ - `content/pages/calendar.md`
53
+ - `content/blocks/upcoming-events.html`
54
+ - `content/views/calendar-upcoming.json`, `content/views/calendar-past.json`, `content/views/calendar-my-events.json`
55
+
56
+ Collections are **not** destroyed on disable (data preserved). Roles are not destroyed on disable.
57
+
58
+ ### `[calendar /]` Shortcode Registration
59
+
60
+ Inside `plugin.js` (the Fastify plugin function), after routes are registered, call:
61
+
62
+ ```js
63
+ options.hooks.registerShortcode('calendar', async (attrs, context) => {
64
+ const limit = parseInt(attrs.limit ?? settings.eventsPerPage, 10);
65
+ const type = attrs.type ?? null;
66
+ // fetch upcoming public occurrences from calendar-occurrences collection
67
+ // return HTML string
68
+ });
69
+ ```
70
+
71
+ `options.hooks` is the `hooks` service injected into every plugin via `registerPlugins`. Handler signature:
72
+
73
+ | Parameter | Type | Notes |
74
+ |---|---|---|
75
+ | `attrs` | `object` | Parsed shortcode attributes (all values are strings) |
76
+ | `context` | `object` | `{ pageUrl, user }` - user may be null for public pages |
77
+
78
+ The handler must return a `string` (or a `Promise<string>`). It is called server-side at render time.
79
+
80
+ Shortcode is unregistered (`options.hooks.unregisterShortcode('calendar')`) in the plugin's cleanup path (called by `teardownPlugin`).
81
+
82
+ ### Form: `calendar-rsvp`
83
+
84
+ File: `plugins/calendar/forms/calendar-rsvp.json`
85
+
86
+ Seeded into `content/forms/calendar-rsvp.json`. Fields:
87
+
88
+ | Field | Type | Notes |
89
+ |---|---|---|
90
+ | `name` | text | Required |
91
+ | `email` | email | Required |
92
+ | `website` | text | Honeypot - hidden via CSS, never shown to user |
93
+
94
+ The form's `submitAction` is set to `{basePath}/rsvp` at seed time (i.e. `/calendar/rsvp` with default config). If `basePath` is changed in settings, re-disable then re-enable the plugin to reseed with the new path. The form does not include an `occurrenceId` field - that is injected directly by the `event.html` public template as a hidden `<input>` outside the form shortcode (see Public Routes section). The event detail page renders the RSVP form inline (not via shortcode). The standalone `[form slug="calendar-rsvp"]` embed use-case (placing the form on an arbitrary CMS page) is **out of scope for v1** - the seeded form JSON is present so the CMS forms admin can display its field structure, but the shortcode embed requires manual wiring of `occurrenceId` which is not supported in this version.
95
+
96
+ ### Page: `calendar` (index)
97
+
98
+ File: `plugins/calendar/pages/calendar.md`
99
+
100
+ Seeded into `content/pages/calendar.md`. Provides the `/calendar` landing page with frontmatter:
101
+
102
+ ```yaml
103
+ ---
104
+ title: Events
105
+ layout: default
106
+ description: Upcoming events
107
+ visibility: public
108
+ ---
109
+ [calendar /]
110
+ ```
111
+
112
+ The `[calendar /]` shortcode (registered on enable) renders the paginated public events listing. Sub-routes (`/calendar/event/:slug`, `/calendar/feed.ics`, `/calendar/rsvp`) are handled by `plugin.public.js` directly. The index page is destroyed on disable; sub-routes stop responding.
113
+
114
+ ### Shortcode: `[calendar /]`
115
+
116
+ Registered via `hooks.registerShortcode('calendar', handler)` on plugin enable. The handler fetches upcoming public occurrences from `calendar-occurrences` (server-side, at render time) and returns an HTML events grid. Supports optional attributes:
117
+
118
+ | Attribute | Default | Notes |
119
+ |---|---|---|
120
+ | `limit` | `eventsPerPage` config value | Max events to show |
121
+ | `type` | (all) | Filter by event type |
122
+
123
+ ### Views (saved)
124
+
125
+ Three saved views seeded into `content/views/`. File format:
126
+
127
+ ```json
128
+ {
129
+ "slug": "calendar-upcoming",
130
+ "label": "Upcoming Events",
131
+ "collection": "calendar-occurrences",
132
+ "filter": { "status": "active" },
133
+ "sort": { "field": "startAt", "direction": "asc" }
134
+ }
135
+ ```
136
+
137
+ | Slug | Label | Filter | Sort |
138
+ |---|---|---|---|
139
+ | `calendar-upcoming` | Upcoming Events | `{ status: 'active' }` | `startAt asc` |
140
+ | `calendar-past` | Past Events | `{}` | `startAt desc` |
141
+ | `calendar-my-events` | My Events | `{}` | `startAt asc` |
142
+
143
+ Removed on disable. These are **advisory views for the admin Collections UI** - they are not used by the plugin's own admin panel (which has its own filter bar and query logic). The `calendar-my-events` view has no special `currentUser` token; it is a blank filter that an admin can manually configure. The complex `organiserId` scoping only applies inside `plugin.js` API routes.
144
+
145
+ ### Block: `upcoming-events`
146
+
147
+ File: `plugins/calendar/blocks/upcoming-events.html`
148
+
149
+ Seeded into `content/blocks/upcoming-events.html`. Embeddable anywhere via `[block name="upcoming-events"]`. Contains the `[calendar limit="3" /]` shortcode, so it re-uses the same shortcode renderer and always shows live data. Use this block to embed an upcoming events teaser on a homepage or landing page.
150
+
151
+ ---
152
+
153
+ ## Recurrence Model
154
+
155
+ Recurrence rules are stored as a JSON object on the parent event - simpler than RRULE strings but covers all common patterns:
156
+
157
+ ```json
158
+ {
159
+ "freq": "weekly",
160
+ "interval": 1,
161
+ "byday": ["MO", "WE"],
162
+ "until": "2026-12-31",
163
+ "count": null
164
+ }
165
+ ```
166
+
167
+ Supported frequencies: `daily`, `weekly`, `monthly`, `yearly`.
168
+
169
+ `generateOccurrences(event, windowDays)` materialises individual `calendar-occurrences` entries for the rolling window. Regeneration is triggered:
170
+ - On `createEvent` - generate `occurrenceWindow` days ahead (default 365)
171
+ - On `updateEvent` - delete occurrences where `startAt > Date.now()` at the moment of the update call, then regenerate from today; past occurrences are never modified
172
+ - On `GET /occurrences?eventId=&from=&to=` - if `eventId` is present and the query's `to` date is within 30 days of that event's `windowGeneratedTo`, trigger a top-up for that specific event only. No top-up occurs when `eventId` is absent (list queries spanning multiple events do not trigger materialisation)
173
+
174
+ **Event deletion:** `DELETE /events/:id` is a **soft cancel** - the parent event and all its occurrences are marked `status: 'cancelled'`; no rows are hard-deleted. Past occurrences and their RSVPs are retained. Only `calendar-admin` may delete events.
175
+
176
+ ---
177
+
178
+ ## Data Model
179
+
180
+ ### Collection: `calendar-events`
181
+
182
+ | Field | Type | Required | Notes |
183
+ |---|---|---|---|
184
+ | `title` | text | yes | |
185
+ | `slug` | text | yes | URL-safe, unique; auto-derived from title |
186
+ | `description` | markdown | no | Rendered on public event page |
187
+ | `type` | select | yes | `meeting` \| `interview` \| `public-event` \| `webinar` \| `other` |
188
+ | `visibility` | select | yes | `public` \| `private` |
189
+ | `location` | text | no | Physical address or video URL |
190
+ | `organiserId` | text | yes | User ID of creator |
191
+ | `recurrence` | object | no | `{ freq, interval, byday, until, count }` - null = one-off |
192
+ | `windowGeneratedTo` | datetime | no | Tracks materialisation horizon |
193
+ | `rsvpEnabled` | boolean | no | Defaults to plugin config `rsvpEnabled` |
194
+ | `rsvpLimit` | number | no | Capacity cap; null = unlimited |
195
+ | `status` | select | yes | `active` \| `cancelled` |
196
+
197
+ ### Collection: `calendar-occurrences`
198
+
199
+ One entry per event instance (materialised from recurrence rule, or standalone for Jobs-created interviews).
200
+
201
+ | Field | Type | Required | Notes |
202
+ |---|---|---|---|
203
+ | `eventId` | text | no | FK to parent event; null for standalone (Jobs-created) occurrences |
204
+ | `title` | text | yes | Defaults to parent title; overridable per-instance |
205
+ | `startAt` | datetime | yes | ISO 8601 UTC string (e.g. `2026-04-25T14:00:00.000Z`) |
206
+ | `endAt` | datetime | yes | ISO 8601 UTC string |
207
+ | `type` | select | yes | Inherited from parent; stored for fast filtering |
208
+ | `visibility` | select | yes | Inherited from parent; stored for fast filtering |
209
+ | `status` | select | yes | `active` \| `cancelled` \| `rescheduled` |
210
+ | `rsvpCount` | number | no | Denormalised; updated on each RSVP |
211
+ | `rsvpLimit` | number | no | Inherited from parent; overridable per-instance |
212
+ | `slug` | text | no | Denormalised from parent event at occurrence creation time; null for Jobs-created standalone occurrences |
213
+ | `source` | text | no | `'calendar'` \| `'jobs'` - identifies the creating plugin |
214
+
215
+ ### Collection: `calendar-rsvps`
216
+
217
+ | Field | Type | Required | Notes |
218
+ |---|---|---|---|
219
+ | `occurrenceId` | text | yes | FK to occurrence |
220
+ | `name` | text | yes | |
221
+ | `email` | text | yes | |
222
+ | `status` | select | yes | `confirmed` \| `cancelled` |
223
+ | `contactId` | text | no | Populated when Contacts plugin is enabled (upsert on submit) |
224
+ | `createdAt` | datetime | yes | Set server-side |
225
+
226
+ ---
227
+
228
+ ## Service Layer (`service.js`)
229
+
230
+ ### Dependencies
231
+
232
+ `service.js` is a plain ES module - no Fastify, no HTTP. It imports directly from CMS core services:
233
+
234
+ ```js
235
+ import { createEntry, getEntry, updateEntry, deleteEntry, listEntries } from '../../server/services/collections.js';
236
+ import { getConfig } from '../../server/config.js';
237
+ ```
238
+
239
+ It does **not** receive an options/settings object - it reads config via `getConfig()` at call time.
240
+
241
+ **Disabled-state guard:** If Calendar is disabled, its collections (`calendar-occurrences`, etc.) will not exist and `createEntry` / `listEntries` will throw a "collection not found" error. Callers (e.g. Jobs) are responsible for checking whether Calendar is enabled before calling service functions. The recommended pattern:
242
+
243
+ ```js
244
+ // In Jobs plugin.js
245
+ const calendarEnabled = options.config?.plugins?.calendar?.enabled ?? false;
246
+ if (calendarEnabled) {
247
+ const { createOccurrence } = await import('../calendar/service.js');
248
+ // ...
249
+ }
250
+ ```
251
+
252
+ `service.js` itself does not guard against this - it trusts that callers have verified the plugin state.
253
+
254
+ ### Exported Functions
255
+
256
+ Exported functions for cross-plugin consumption. Jobs and other plugins import directly:
257
+
258
+ ```js
259
+ import {
260
+ createEvent,
261
+ updateEvent,
262
+ cancelEvent,
263
+ createOccurrence,
264
+ getOccurrence,
265
+ cancelOccurrence,
266
+ generateOccurrences
267
+ } from '../calendar/service.js';
268
+ ```
269
+
270
+ ### Function signatures
271
+
272
+ | Function | Purpose |
273
+ |---|---|
274
+ | `createEvent(data)` | Create parent event + materialise occurrences for `occurrenceWindow` days |
275
+ | `updateEvent(id, data)` | Update parent + delete/regenerate future occurrences |
276
+ | `cancelEvent(id)` | Mark parent cancelled; cancel all future occurrences |
277
+ | `createOccurrence(data)` | Create standalone occurrence (no parent) - primary Jobs entry point |
278
+ | `getOccurrence(id)` | Fetch single occurrence by ID |
279
+ | `cancelOccurrence(id)` | Cancel a single instance without affecting the series |
280
+ | `generateOccurrences(event, windowDays)` | Internal - materialise occurrences; exposed for admin top-up |
281
+
282
+ **Jobs integration example:**
283
+ ```js
284
+ // In plugins/jobs/plugin.js
285
+ import { createOccurrence, cancelOccurrence } from '../calendar/service.js';
286
+
287
+ const slot = await createOccurrence({
288
+ title: `Interview: ${candidate.name}`,
289
+ type: 'interview',
290
+ visibility: 'private',
291
+ startAt: scheduledAt,
292
+ endAt: endAt,
293
+ organiserId: recruiterId,
294
+ source: 'jobs'
295
+ });
296
+ // Store slot.id on the application record
297
+ ```
298
+
299
+ Calendar does not import anything from Jobs - the dependency is one-way.
300
+
301
+ ---
302
+
303
+ ## Plugin Roles
304
+
305
+ ### `calendar-organiser`
306
+ ```json
307
+ {
308
+ "name": "calendar-organiser",
309
+ "label": "Calendar Organiser",
310
+ "level": 2,
311
+ "permissions": ["calendar-events", "calendar-occurrences"],
312
+ "badgeClass": "badge-info",
313
+ "resources": [
314
+ { "key": "calendar-events", "label": "Calendar Events", "actions": ["read", "create", "update", "delete"] },
315
+ { "key": "calendar-occurrences", "label": "Calendar Occurrences", "actions": ["read", "update", "delete"] }
316
+ ]
317
+ }
318
+ ```
319
+ Organisers can create, edit, and delete their own events and occurrences. They cannot modify events owned by others, view RSVPs, or change plugin settings.
320
+
321
+ ### `calendar-admin`
322
+ ```json
323
+ {
324
+ "name": "calendar-admin",
325
+ "label": "Calendar Admin",
326
+ "level": 1,
327
+ "permissions": ["calendar-events", "calendar-occurrences", "calendar-rsvps", "calendar-settings"],
328
+ "badgeClass": "badge-success",
329
+ "resources": [
330
+ { "key": "calendar-events", "label": "Calendar Events", "actions": ["read", "create", "update", "delete"] },
331
+ { "key": "calendar-occurrences", "label": "Calendar Occurrences", "actions": ["read", "create", "update", "delete"] },
332
+ { "key": "calendar-rsvps", "label": "Calendar RSVPs", "actions": ["read", "update", "delete"] },
333
+ { "key": "calendar-settings", "label": "Calendar Settings", "actions": ["read", "update"] }
334
+ ]
335
+ }
336
+ ```
337
+
338
+ ---
339
+
340
+ ## API Routes (`plugin.js`)
341
+
342
+ All under `/api/plugins/calendar`. All routes require `authenticate`.
343
+
344
+ | Method | Path | Auth | Purpose |
345
+ |---|---|---|---|
346
+ | GET | `/events` | organiser+ | List parent events |
347
+ | POST | `/events` | organiser+ | Create event + materialise occurrences |
348
+ | GET | `/events/:id` | organiser+ | Get event |
349
+ | PUT | `/events/:id` | organiser (own) / admin | Update + regenerate occurrences |
350
+ | DELETE | `/events/:id` | admin | Cancel event + future occurrences |
351
+ | GET | `/occurrences` | organiser+ | List occurrences (`?from=&to=&type=&visibility=&eventId=`) |
352
+ | GET | `/occurrences/:id` | organiser+ | Get single occurrence |
353
+ | PUT | `/occurrences/:id` | organiser (own) / admin | Override title/time for single instance |
354
+ | DELETE | `/occurrences/:id` | organiser (own) / admin | Cancel single instance |
355
+ | GET | `/rsvps` | organiser+ | List RSVPs (`?occurrenceId=&status=`) |
356
+ | PUT | `/rsvps/:id` | organiser+ | Update RSVP status |
357
+ | DELETE | `/rsvps/:id` | organiser+ | Delete RSVP |
358
+ | GET | `/settings` | admin | Get plugin settings |
359
+ | PUT | `/settings` | admin | Save settings to `config/plugins.json` |
360
+
361
+ **Ownership rule:** `calendar-organiser` users can only update/delete events and occurrences where `organiserId === request.user.id`. Use a named-role bypass list (same pattern as the blog plugin's `PUBLISH_ROLES`):
362
+
363
+ ```js
364
+ const ADMIN_ROLES = ['calendar-admin', 'admin', 'super-admin'];
365
+ // In route handler:
366
+ if (!ADMIN_ROLES.includes(request.user.role) && event.organiserId !== request.user.id) {
367
+ return reply.code(403).send({ error: 'Forbidden' });
368
+ }
369
+ ```
370
+
371
+ Do NOT use `getRoleLevel()` for this check - `calendar-organiser` is level 2 and a base `user` is also level 2; level comparison would incorrectly allow base users through.
372
+
373
+ **RSVP visibility:** `GET /rsvps` for a `calendar-organiser` is scoped to occurrences belonging to their own events (`organiserId === request.user.id`). `calendar-admin`, `admin`, and `super-admin` see all RSVPs regardless of ownership.
374
+
375
+ ---
376
+
377
+ ## Public Routes (`plugin.public.js`)
378
+
379
+ `plugin.public.js` is registered at the Fastify root (before `routes/public.js`) by `server.js`. It receives the same options object as `plugin.js`:
380
+
381
+ ```js
382
+ { settings, auth: { authenticate, requireAdmin }, config }
383
+ ```
384
+
385
+ `basePath` is resolved inside `plugin.public.js` as `options.settings?.basePath ?? '/calendar'`. All routes are registered under `basePath`. Static routes registered before `/:slug`.
386
+
387
+ | Method | Path | Purpose |
388
+ |---|---|---|
389
+ | GET | `/` | Public events listing - paginated, published events only |
390
+ | GET | `/feed.ics` | iCal 2.0 feed - public occurrences only, up to `feedLength` - registered first |
391
+ | GET | `/event/:slug` | Individual event page + RSVP form (when `rsvpEnabled`) |
392
+ | POST | `/rsvp` | Submit RSVP - no auth, honeypot protected |
393
+
394
+ **RSVP submission (`POST /rsvp`):**
395
+ 1. Validate required fields (`occurrenceId`, `name`, `email`)
396
+ 2. Honeypot check - if `website` field non-empty, silently return 201
397
+ 3. Duplicate check - if a `confirmed` RSVP already exists for the same `occurrenceId` + `email`, return 200 (idempotent, no new row created)
398
+ 4. Check `rsvpLimit` - if set and `rsvpCount >= rsvpLimit`, return 409
399
+ 5. Create `calendar-rsvps` entry with `status: 'confirmed'`, `createdAt: new Date().toISOString()`
400
+ 6. Increment `rsvpCount` on the occurrence
401
+ 7. If Contacts plugin is enabled: upsert into `contacts-contacts` matching on `email`; write `{ name, email, source: 'calendar' }`; store returned contact ID as `contactId` on the RSVP record
402
+
403
+ **iCal feed format:** Standard iCalendar 2.0 (`text/calendar`). All datetimes converted from ISO 8601 UTC to iCal format (`YYYYMMDDTHHMMSSZ`). Each public occurrence becomes a `VEVENT` with:
404
+
405
+ | iCal field | Value |
406
+ |---|---|
407
+ | `UID` | `<occurrenceId>@<siteHostname>` (e.g. `abc123@example.com`) |
408
+ | `DTSTAMP` | Feed generation time in `YYYYMMDDTHHMMSSZ` format (required by RFC 5545) |
409
+ | `DTSTART` | `startAt` converted to `YYYYMMDDTHHMMSSZ` |
410
+ | `DTEND` | `endAt` converted to `YYYYMMDDTHHMMSSZ` |
411
+ | `SUMMARY` | `title` |
412
+ | `DESCRIPTION` | `description` (plain text, newlines as `\n`) |
413
+ | `LOCATION` | `location` (omitted if empty) |
414
+ | `URL` | `<siteUrl>/calendar/event/<slug>` - omitted when `slug` is null (Jobs-created occurrences) |
415
+
416
+ `siteHostname` and `siteUrl` resolved from `config.site.url` with a fallback to `'localhost'`.
417
+
418
+ ---
419
+
420
+ ## Admin UI
421
+
422
+ Single "Calendar" sidebar entry with sub-navigation.
423
+
424
+ ### Events list (`#/plugins/calendar`)
425
+ - Three view modes, toggled at top right, persisted via `S.set('calendar_view', mode)`:
426
+ - **Grid** - month calendar grid; click day to see/create events
427
+ - **List** - `T.create()` table: title, next occurrence, type badge, visibility badge, RSVP count, edit/delete actions
428
+ - **Split** - compact month picker left; selected day's occurrences right
429
+ - Filter bar: type select, visibility select, date range
430
+ - "New Event" button → navigates to editor
431
+
432
+ ### Event editor (`#/plugins/calendar/events/new` and `#/plugins/calendar/events/:id`)
433
+ - Two-column layout:
434
+ - **Main**: title, slug (auto-derive toggle), description (markdown textarea), type, visibility, location, start/end datetime
435
+ - **Sidebar**: recurrence panel (checkbox to enable; freq/interval/until/byday controls), RSVP panel (enable toggle, capacity input)
436
+ - Jobs-created events (`source: 'jobs'`) are shown read-only with a "Managed by Jobs" label
437
+
438
+ ### RSVPs (`#/plugins/calendar/rsvps`)
439
+ - Filterable by occurrence (select dropdown of upcoming events)
440
+ - Table: name, email, event title, occurrence date, status, created at
441
+ - Per-row: confirm / cancel / delete actions
442
+
443
+ ### Settings (`#/plugins/calendar/settings`)
444
+ - `F.create()` form: `basePath`, `eventsPerPage`, `rsvpEnabled`, `occurrenceWindow`, `feedLength`
445
+ - Banner: "Base path changes require a server restart"
446
+
447
+ ---
448
+
449
+ ## `config.js` Defaults
450
+
451
+ ```js
452
+ export default {
453
+ basePath: '/calendar',
454
+ eventsPerPage: 12,
455
+ rsvpEnabled: true,
456
+ defaultView: 'list', // 'grid' | 'list' | 'split'
457
+ occurrenceWindow: 365, // days ahead to materialise occurrences
458
+ feedLength: 50,
459
+ storage: { adapter: 'file' }
460
+ };
461
+ ```
462
+
463
+ ---
464
+
465
+ ## Plugin Layout
466
+
467
+ ```
468
+ plugins/calendar/
469
+ plugin.json
470
+ plugin.js API routes
471
+ plugin.public.js Public calendar page + iCal feed + RSVP POST
472
+ service.js Exported createEvent/createOccurrence etc. for Jobs + other plugins
473
+ config.js
474
+ CLAUDE.md
475
+ roles/
476
+ calendar-organiser.json
477
+ calendar-admin.json
478
+ collections/
479
+ calendar-events/schema.json
480
+ calendar-occurrences/schema.json
481
+ calendar-rsvps/schema.json
482
+ forms/
483
+ calendar-rsvp.json Seeded RSVP form (public event page uses [form slug="calendar-rsvp"])
484
+ pages/
485
+ calendar.md Seeded index page at /calendar; contains [calendar /] shortcode
486
+ blocks/
487
+ upcoming-events.html Seeded block; embeddable via [block name="upcoming-events"]
488
+ templates/
489
+ index.html Public events listing body template
490
+ event.html Public event detail body template
491
+ admin/
492
+ views/
493
+ calendar.js Events list (all 3 view modes)
494
+ event-editor.js Create/edit event + recurrence
495
+ rsvps.js RSVPs list + moderation
496
+ settings.js
497
+ templates/
498
+ calendar.html
499
+ event-editor.html
500
+ rsvps.html
501
+ settings.html
502
+ ```
503
+
504
+ ---
505
+
506
+ ## Build Sequence
507
+
508
+ 1. **Collection schemas** - `calendar-events`, `calendar-occurrences`, `calendar-rsvps`
509
+ 2. **Role files** - `calendar-organiser.json`, `calendar-admin.json`
510
+ 3. **`service.js`** - `createEvent`, `updateEvent`, `cancelEvent`, `createOccurrence`, `getOccurrence`, `cancelOccurrence`, `generateOccurrences`
511
+ 4. **`plugin.js`** - API routes (events, occurrences, RSVPs, settings)
512
+ 5. **`plugin.public.js`** - public listing, iCal feed, event page, RSVP POST
513
+ 6. **Public templates** - `templates/index.html`, `templates/event.html`
514
+ 7. **Seeded assets** - `forms/calendar-rsvp.json`, `pages/calendar.md`, `blocks/upcoming-events.html`; registered views defined in `setupPlugin` call (three view JSON files)
515
+ 8. **Admin templates** - `calendar.html`, `event-editor.html`, `rsvps.html`, `settings.html`
516
+ 9. **Admin views** - `calendar.js` (3 view modes), `event-editor.js`, `rsvps.js`, `settings.js`
517
+ 10. **`plugin.json` + `config.js` + `CLAUDE.md`**
518
+ 11. **`config/plugins.json`** - add calendar entry (disabled by default)
519
+
520
+ ---
521
+
522
+ ## Verification
523
+
524
+ - **Fresh install**: enable Calendar → three collections appear in `content/collections/`, two roles in `content/collections/roles/data.json`, `content/pages/calendar.md` exists, `content/forms/calendar-rsvp.json` exists, `content/blocks/upcoming-events.html` exists
525
+ - **Disable plugin**: disable Calendar → page, form, block removed from `content/`; `GET /calendar` returns 404; iCal feed returns 404
526
+ - **Shortcode embed**: add `[calendar limit="3" /]` to any page → renders up to 3 upcoming public events inline
527
+ - **Block embed**: add `[block name="upcoming-events"]` to any page → renders same events via the seeded block
528
+ - **One-off event**: create a public-event → one occurrence in `calendar-occurrences` → `GET /calendar/event/:slug` returns 200
529
+ - **Recurring event**: create weekly event with `until` 4 weeks out → 4 occurrences materialised → all appear in `GET /calendar`
530
+ - **Update recurrence**: change `until` → future occurrences regenerated; past occurrences preserved
531
+ - **iCal feed**: `GET /calendar/feed.ics` returns `text/calendar` with `BEGIN:VCALENDAR` and correct `VEVENT` entries for public occurrences only
532
+ - **RSVP flow**: submit RSVP on public page → entry in `calendar-rsvps` → `rsvpCount` incremented on occurrence → appears in admin RSVPs view
533
+ - **Contacts integration**: submit RSVP with Contacts enabled → contact upserted in `contacts-contacts` with `source: 'calendar'`
534
+ - **Capacity limit**: set `rsvpLimit: 2`, submit 3 RSVPs → third returns 409
535
+ - **Jobs integration**: call `createOccurrence({ source: 'jobs', type: 'interview', ... })` → occurrence created → appears read-only in Calendar admin with "Jobs" label
536
+ - **View modes**: toggle Grid / List / Split → preference persisted across page navigations via `S.set()`
537
+ - **Role gate**: `calendar-organiser` can edit own events; attempting to edit another user's event returns 403; `calendar-admin` can edit any
538
+ - **Private events**: private occurrences do not appear on `GET /calendar` or in `feed.ics`; direct `GET /calendar/event/:slug` for a private event returns 404