domma-cms 1.6.60 → 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 (137) 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/lib/effect-defs.js +1 -1
  6. package/admin/js/lib/effects-builder.js +2 -2
  7. package/admin/js/lib/page-editor-reference.js +5 -5
  8. package/admin/js/lib/shortcode-tags.js +1 -1
  9. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  10. package/docs/DommaFeatures.md +3005 -0
  11. package/docs/MultiTenant.md +176 -0
  12. package/docs/api-reference.md +1667 -0
  13. package/docs/cache.md +96 -0
  14. package/docs/configuration.md +1811 -0
  15. package/docs/editions-and-licences.md +190 -0
  16. package/docs/getting-started.md +201 -0
  17. package/docs/guides/form-triggers.md +260 -0
  18. package/docs/guides/mongodb-storage-adapter.md +282 -0
  19. package/docs/guides/multi-page-forms.md +121 -0
  20. package/docs/images/scribe-composer.png +0 -0
  21. package/docs/markdown-shortcodes.md +1738 -0
  22. package/docs/pentest-report-2026-04-03.md +181 -0
  23. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  24. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  25. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  26. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  27. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  28. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  29. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  30. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  31. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  32. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  33. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  34. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  35. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  36. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  37. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  38. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  39. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  40. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  41. package/docs/plugin-development.md +827 -0
  42. package/docs/recruitment-recipe.md +501 -0
  43. package/docs/scaffolding.md +312 -0
  44. package/docs/screenshots/calculator-floating.png +0 -0
  45. package/docs/screenshots/calculator-standard.png +0 -0
  46. package/docs/screenshots/email-settings.png +0 -0
  47. package/docs/screenshots/messages-count.png +0 -0
  48. package/docs/structure/user-types.md +107 -0
  49. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  50. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  51. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  52. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  53. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  54. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  55. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  56. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  57. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  58. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  59. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  60. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  61. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  62. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  63. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  64. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  65. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  66. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  67. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  68. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  69. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  70. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  71. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  72. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  73. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  74. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  75. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  76. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  77. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  78. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  79. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  80. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  81. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  82. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  83. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  84. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  85. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  86. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  87. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  88. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  89. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  90. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  91. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  92. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  93. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  94. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  95. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  96. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  97. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  98. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  99. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  100. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  101. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  102. package/docs/theming.md +320 -0
  103. package/package.json +2 -2
  104. package/public/js/celebrations/core/canvas.js +1 -1
  105. package/public/js/celebrations/index.js +1 -1
  106. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  107. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  108. package/public/js/celebrations/message-attrs.mjs +1 -0
  109. package/public/js/celebrations/themes/anniversary.js +1 -0
  110. package/public/js/celebrations/themes/autumn.js +1 -0
  111. package/public/js/celebrations/themes/birthday.js +1 -0
  112. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  113. package/public/js/celebrations/themes/calendar.js +1 -0
  114. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  115. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  116. package/public/js/celebrations/themes/christmas.js +1 -1
  117. package/public/js/celebrations/themes/easter.js +1 -0
  118. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  119. package/public/js/celebrations/themes/garden.js +1 -0
  120. package/public/js/celebrations/themes/graduation.js +1 -0
  121. package/public/js/celebrations/themes/greeting.js +1 -0
  122. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  123. package/public/js/celebrations/themes/money-rain.js +1 -0
  124. package/public/js/celebrations/themes/new-year.js +1 -0
  125. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  126. package/public/js/celebrations/themes/occasions.js +1 -0
  127. package/public/js/celebrations/themes/rain-control.js +1 -0
  128. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  129. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  130. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  131. package/public/js/celebrations/themes/summer-garden.js +1 -0
  132. package/public/js/celebrations/themes/winter.js +1 -0
  133. package/public/js/celebrations/trait-data.mjs +1 -1
  134. package/public/js/effects.js +1 -1
  135. package/scripts/build.js +4 -0
  136. package/server/services/markdown.js +5 -2
  137. package/server/templates/page.html +1 -1
@@ -0,0 +1,501 @@
1
+ # Recipe - Building a Job Board with Stock Primitives
2
+
3
+ Domma CMS doesn't ship a recruitment plugin, but every primitive you need for a working job board is in the base
4
+ distribution. This recipe builds an end-to-end candidate/employer flow from scratch using only:
5
+
6
+ - **Collections** with structured filtering
7
+ - **Forms** that capture applications
8
+ - **Actions** that link applications to jobs
9
+ - **Pages** with role-based visibility and per-user scoping
10
+
11
+ The same pattern works for any relational app: bookings, classified ads, marketplace listings, bug trackers, RSVPs.
12
+
13
+ ---
14
+
15
+ ## Architecture overview
16
+
17
+ ```
18
+ content/collections/jobs/ ← postings authored by employers
19
+ content/collections/applications/ ← per-candidate applications linking back to jobs
20
+ content/pages/jobs/index.md ← public job listing with filters
21
+ content/pages/dashboard.md ← signed-in dashboard with "My applications"
22
+ content/forms/apply.json ← form embedded on each job page
23
+ Actions:
24
+ - "submit-application" creates an application linked to the job + candidate
25
+ - "withdraw-application" deletes an application owned by the candidate
26
+ Roles (added to content/collections/roles/):
27
+ - candidate (level 5)
28
+ - employer (level 4)
29
+ ```
30
+
31
+ ---
32
+
33
+ ## 1. Add the roles
34
+
35
+ In the admin panel **System > Roles** (or by editing `content/collections/roles/data.json`):
36
+
37
+ ```json
38
+ {
39
+ "name": "candidate",
40
+ "label": "Candidate",
41
+ "level": 5,
42
+ "permissions": ["notifications"],
43
+ "badgeClass": "badge-info"
44
+ }
45
+ {
46
+ "name": "employer",
47
+ "label": "Employer",
48
+ "level": 4,
49
+ "permissions": ["notifications"],
50
+ "badgeClass": "badge-secondary"
51
+ }
52
+ ```
53
+
54
+ Roles are dynamic - once saved, every visibility check and `where_role=` filter recognises them immediately.
55
+
56
+ The base roles are `super-admin` (0), `admin` (1) and `user` (2), so levels 4 and 5 sit below `user`: a plain
57
+ `user` counts as "more senior" than a candidate. Use the exact form (`visibility: "=candidate"`) where a page or
58
+ menu item must be for candidates only.
59
+
60
+ ---
61
+
62
+ ## 2. Create the `jobs` collection
63
+
64
+ Schema fields (admin → Collections → New):
65
+
66
+ | Field | Type | Notes |
67
+ |------------|------------|------------------------------------|
68
+ | `title` | text | Job title |
69
+ | `company` | text | Hiring organisation |
70
+ | `location` | text | City or "Remote" |
71
+ | `type` | select | `full-time`, `contract`, `part-time` |
72
+ | `salary` | number | Annual GBP |
73
+ | `tags` | multiselect| `remote`, `hybrid`, `on-site`, etc. |
74
+ | `summary` | textarea | Short pitch |
75
+ | `body` | rich-text | Full description |
76
+ | `postedAt` | date | Defaults to `createdAt` |
77
+
78
+ **Permissions:**
79
+
80
+ - `api.read` = enabled, access = `public` - anyone can list jobs
81
+ - `api.create`, `update`, `delete` = enabled, access = `employer` - employers post their own roles
82
+ - Add a **row-level access** rule (`content/collections/jobs/schema.json → rowAccess`):
83
+ ```json
84
+ "rowAccess": {
85
+ "update": { "ownerField": "createdBy", "fallbackRoles": ["admin", "super-admin"] },
86
+ "delete": { "ownerField": "createdBy", "fallbackRoles": ["admin", "super-admin"] }
87
+ }
88
+ ```
89
+ Employers can only edit/delete jobs they created; admins can manage all.
90
+
91
+ ---
92
+
93
+ ## 3. Create the `applications` collection
94
+
95
+ Fields:
96
+
97
+ | Field | Type | Notes |
98
+ |---------------|-------------|--------------------------------------------------------------------------|
99
+ | `jobId` | **reference** | `reference: { collection: "jobs", displayField: "title", linkTemplate: "/jobs/{{id}}" }` |
100
+ | `candidateId` | text | Set automatically to `meta.createdBy` |
101
+ | `coverLetter` | textarea | What the candidate wrote |
102
+ | `resume` | **file** | `file: { accept: "application/pdf", maxSize: 5242880 }` - PDF up to 5 MB |
103
+ | `status` | select | `submitted`, `reviewing`, `interview`, `offered`, `rejected`, `withdrawn`|
104
+ | `submittedAt` | date | Defaults to `createdAt` |
105
+
106
+ The `jobId` is a **reference** to the `jobs` collection. This single change
107
+ means:
108
+
109
+ - The apply form auto-renders a populated dropdown of job titles (no manual
110
+ options or denormalised `jobTitle` field needed)
111
+ - The candidate's "My applications" view shows the job title - not the id -
112
+ because resolution happens automatically when listing
113
+ - Each row links to `/jobs/<id>` for the original posting (via `linkTemplate`)
114
+ - Validation prevents stale apps pointing at deleted jobs (you'll get
115
+ `"Job" → no entry "<id>" in "jobs"` on create)
116
+
117
+ **Permissions:**
118
+
119
+ - `api.read` = **disabled** (we'll use `scope="mine"` instead - see step 7)
120
+ - `api.create` = **disabled** (created via Action, not directly)
121
+ - No row-level rules needed - the scope=mine endpoint already restricts to `createdBy = user.id`
122
+
123
+ ---
124
+
125
+ ## 4. Build the public job listings page
126
+
127
+ Create `content/pages/jobs/index.md`:
128
+
129
+ ```markdown
130
+ ---
131
+ title: Jobs
132
+ visibility: public
133
+ ---
134
+
135
+ ## Find your next role
136
+
137
+ [collection slug="jobs"
138
+ display="cards" columns="3"
139
+ searchable
140
+ filterable="location,type,salary,tags"
141
+ sortable
142
+ page-size="9"
143
+ title-field="title"
144
+ fields="company,location,salary,type,tags"
145
+ sort="postedAt" order="desc"
146
+ cta="apply-flow"
147
+ cta-label="View & Apply"
148
+ cta-icon="briefcase"
149
+ empty="No matching roles right now - try widening your filters." /]
150
+ ```
151
+
152
+ That single shortcode renders:
153
+
154
+ ```
155
+ ┌────────────────────────────────────────────────────────────┐
156
+ │ [🔍 Search…] [Sort: Most recent ↓] │
157
+ ├──────────────┬─────────────────────────────────────────────┤
158
+ │ Filters │ ┌────────┐ ┌────────┐ ┌────────┐ │
159
+ │ ────────── │ │ Job #1 │ │ Job #2 │ │ Job #3 │ │
160
+ │ Location │ └────────┘ └────────┘ └────────┘ │
161
+ │ [London ▾] │ ┌────────┐ ┌────────┐ ┌────────┐ │
162
+ │ Type │ │ Job #4 │ │ Job #5 │ │ Job #6 │ │
163
+ │ ☐ Full-time │ └────────┘ └────────┘ └────────┘ │
164
+ │ ☐ Contract │ ┌────────┐ ┌────────┐ ┌────────┐ │
165
+ │ Salary │ │ Job #7 │ │ Job #8 │ │ Job #9 │ │
166
+ │ [50k]──●─── │ └────────┘ └────────┘ └────────┘ │
167
+ │ Tags ├─────────────────────────────────────────────┤
168
+ │ ☐ remote │ ‹ Prev Page 1 of 4 Next › │
169
+ │ ☐ hybrid │ │
170
+ └──────────────┴─────────────────────────────────────────────┘
171
+ ```
172
+
173
+ - The filter rail is **derived from the schema**: `location` (text) becomes a dropdown of distinct values, `type`
174
+ (select) becomes a single-pick dropdown, `salary` (number) becomes a min/max range, `tags` (multiselect) becomes
175
+ a checkbox group. No JS authoring required.
176
+ - Every change updates the URL: visiting `/jobs?cb_jobs-N_filter[location]=London&cb_jobs-N_filter[salary_gte]=50000`
177
+ deep-links to that exact filtered view - shareable, bookmarkable, back-button-friendly.
178
+ - All client-side by default (mode=client) - instant response.
179
+
180
+ **For a pre-narrowed landing page** like `/jobs/well-paid`, bake in a constraint that users can refine but not
181
+ escape:
182
+
183
+ ```markdown
184
+ ---
185
+ title: Well-paid Roles
186
+ ---
187
+
188
+ [collection slug="jobs"
189
+ display="cards"
190
+ searchable filterable="location,type" sortable
191
+ where_salary_gte="50000"
192
+ where_type_in="full-time,contract" /]
193
+ ```
194
+
195
+ The `where_*` attributes are locked in - the filter rail lets visitors narrow further (e.g. only London full-time)
196
+ but can never widen back to part-time or sub-£50k roles. Server-rendered initial view + interactive refinement,
197
+ in one shortcode.
198
+
199
+ **Mongo-backed at scale.** Once `jobs` outgrows the in-memory cap (default 1000 entries), add `mode="server"`:
200
+
201
+ ```markdown
202
+ [collection slug="jobs" searchable filterable="location,type,salary,tags"
203
+ sortable mode="server" page-size="12" /]
204
+ ```
205
+
206
+ Every filter change becomes a `GET /api/collections/jobs/public?filter[…]&page=…` round-trip; MongoAdapter
207
+ translates the filter to a native `$gte`/`$in`/regex query and hits a B-tree index. Same UX, no payload bloat.
208
+
209
+ ---
210
+
211
+ ## 5. Build the apply form
212
+
213
+ Create `content/forms/apply.json` (or via the admin Forms editor):
214
+
215
+ ```json
216
+ {
217
+ "slug": "apply",
218
+ "title": "Apply for this role",
219
+ "fields": [
220
+ {
221
+ "name": "jobId", "type": "reference", "required": true, "label": "Position",
222
+ "reference": { "collection": "jobs", "displayField": "title" }
223
+ },
224
+ { "name": "coverLetter", "type": "textarea", "required": true, "label": "Cover letter" },
225
+ {
226
+ "name": "resume", "type": "file", "required": true, "label": "Resume (PDF)",
227
+ "file": { "accept": "application/pdf", "maxSize": 5242880 }
228
+ }
229
+ ],
230
+ "settings": {
231
+ "successMessage": "Application submitted!",
232
+ "successRedirect": "/dashboard",
233
+ "actionSlug": "submit-application",
234
+ "rateLimitPerMinute": 3,
235
+ "honeypot": true
236
+ },
237
+ "actions": {
238
+ "collection": { "enabled": false }
239
+ }
240
+ }
241
+ ```
242
+
243
+ Embed it on a generic apply page:
244
+
245
+ ```markdown
246
+ [form slug="apply" /]
247
+ ```
248
+
249
+ That's the whole form. The reference field auto-renders as a populated
250
+ dropdown of all open jobs - server-side at render time, no extra round-trips,
251
+ no JS-side fetching. No hidden ids, no denormalised job titles, no manual
252
+ glue. **The form file declares the relationship and the platform handles the
253
+ rest.**
254
+
255
+ ---
256
+
257
+ ## 6. Wire the apply action
258
+
259
+ The form's `settings.actionSlug` references `submit-application`. Create this action (admin → Actions → New):
260
+
261
+ ```json
262
+ {
263
+ "slug": "submit-application",
264
+ "title": "Submit Application",
265
+ "collection": "form-submissions",
266
+ "access": { "roles": ["candidate", "admin", "super-admin"] },
267
+ "steps": [
268
+ {
269
+ "type": "createInCollection",
270
+ "config": {
271
+ "targetCollection": "applications",
272
+ "createdBy": "{{user.id}}",
273
+ "data": {
274
+ "jobId": "{{entry.data.jobId}}",
275
+ "candidateId": "{{user.id}}",
276
+ "coverLetter": "{{entry.data.coverLetter}}",
277
+ "status": "submitted",
278
+ "submittedAt": "{{now}}"
279
+ }
280
+ }
281
+ },
282
+ {
283
+ "type": "email",
284
+ "config": {
285
+ "to": "{{user.email}}",
286
+ "subject": "We received your application for {{entry.data.jobTitle}}",
287
+ "template": "Hi {{user.name}},\n\nThanks for applying - we'll be in touch.\n"
288
+ }
289
+ }
290
+ ]
291
+ }
292
+ ```
293
+
294
+ **Key trick:** the public form submission endpoint (`POST /api/forms/submit/:slug`) does an *optional* JWT verification.
295
+ Signed-in candidates get their identity captured automatically; `{{user.id}}` and `{{user.email}}` resolve correctly
296
+ inside the action's templates. Anonymous applicants would get blank values - for the recruitment use case, gate the
297
+ job-detail page with `visibility: candidate` to force sign-in.
298
+
299
+ The `createInCollection` step is new (introduced alongside this recipe). Unlike `moveToCollection`, it does **not**
300
+ delete the source entry - so the original `form-submissions` record remains as an audit trail and the new
301
+ `applications` entry is what the dashboard reads.
302
+
303
+ ---
304
+
305
+ ## 7. Candidate dashboard with "My applications"
306
+
307
+ Create `content/pages/dashboard.md`:
308
+
309
+ ```markdown
310
+ ---
311
+ title: My Dashboard
312
+ visibility: [candidate, employer]
313
+ ---
314
+
315
+ ## My Applications
316
+
317
+ [collection slug="applications"
318
+ scope="mine"
319
+ display="cards"
320
+ title-field="jobId"
321
+ fields="jobId,status,submittedAt"
322
+ sort="submittedAt"
323
+ order="desc"
324
+ cta="withdraw-application"
325
+ cta-label="Withdraw"
326
+ cta-icon="x-circle"
327
+ cta-style="danger"
328
+ cta-confirm="Withdraw this application?"
329
+ empty="You haven't applied for any roles yet." /]
330
+ ```
331
+
332
+ `title-field="jobId"` doesn't show the raw id - because `jobId` is a
333
+ **reference**, the platform resolves it to the job's title automatically. The
334
+ card title reads "Senior Engineer", clickable to `/jobs/<id>` thanks to the
335
+ `linkTemplate` on the schema. No denormalisation needed, no broken links when
336
+ a job is updated.
337
+
338
+ `visibility: [candidate, employer]` - both roles see the page; super-admin and admin inherit access automatically.
339
+ `scope="mine"` makes the block render client-side via `POST /api/collections/render-scope`, which auto-applies
340
+ `createdBy = <current user id>`. The page itself stays cached per role; only the per-user data is fetched per visit.
341
+
342
+ ---
343
+
344
+ ## 8. Wire state-machine transitions
345
+
346
+ Define **one action per legal state transition** and let the platform render
347
+ the right buttons for the right row's current state. Add `transition` to each:
348
+
349
+ ```json
350
+ {
351
+ "slug": "withdraw-application",
352
+ "title": "Withdraw",
353
+ "collection": "applications",
354
+ "transition": { "field": "status", "from": ["submitted", "reviewing"], "to": "withdrawn" },
355
+ "access": { "roles": ["candidate"], "rowLevel": { "mode": "owner" } },
356
+ "steps": [
357
+ { "type": "updateField", "config": { "field": "status", "value": "withdrawn" } }
358
+ ]
359
+ }
360
+ ```
361
+
362
+ ```json
363
+ {
364
+ "slug": "start-review",
365
+ "title": "Move to reviewing",
366
+ "collection": "applications",
367
+ "transition": { "field": "status", "from": ["submitted"], "to": "reviewing" },
368
+ "access": { "roles": ["admin", "hr"] },
369
+ "steps": [
370
+ { "type": "updateField", "config": { "field": "status", "value": "reviewing" } },
371
+ { "type": "email", "config": {
372
+ "to": "{{user.email}}",
373
+ "subject": "{{entry.data.jobId}} - moved to review",
374
+ "template": "Application for {{entry.data.jobId}} is now in review."
375
+ }}
376
+ ]
377
+ }
378
+ ```
379
+
380
+ ```json
381
+ {
382
+ "slug": "schedule-interview",
383
+ "title": "Invite to interview",
384
+ "collection": "applications",
385
+ "transition": { "field": "status", "from": ["reviewing"], "to": "interview" },
386
+ "access": { "roles": ["admin", "hr"] },
387
+ "steps": [{ "type": "updateField", "config": { "field": "status", "value": "interview" }}]
388
+ }
389
+ ```
390
+
391
+ The candidate's dashboard from step 7 just needs `paginate` and `transitions`
392
+ added. Transition buttons are drawn by the interactive Collection Browser, and
393
+ `paginate` (or `searchable`, `sortable`, `filterable`) is what turns it on - a
394
+ `scope="mine"` block then hydrates as the Browser over the candidate's own rows:
395
+
396
+ ```markdown
397
+ [collection slug="applications" scope="mine" display="cards"
398
+ title-field="jobId"
399
+ fields="jobId,status,submittedAt,resume"
400
+ paginate
401
+ transitions
402
+ empty="You haven't applied for any roles yet." /]
403
+ ```
404
+
405
+ Now each application card automatically shows the right buttons:
406
+
407
+ - `submitted` → "Withdraw"
408
+ - `reviewing` → "Withdraw"
409
+ - `interview` → (none - candidate can't withdraw from an active interview process)
410
+ - `offered` / `rejected` → (none - terminal)
411
+
412
+ The `resume` field renders as a clickable link to the candidate's uploaded
413
+ PDF; the recruiter's parallel dashboard (an interactive `display="table"`
414
+ block with no `scope`) sees the same files and can run their own transitions
415
+ (`start-review`, `schedule-interview`, etc.) thanks to the role-based
416
+ filter in `listTransitionsForEntry`.
417
+
418
+ `access.roles: ["admin", "hr"]` means either role is enough (and anyone more
419
+ senior): an `hr` user runs `start-review` although `admin` ranks above them.
420
+
421
+ Everything the candidate's block does stays inside their own rows on the
422
+ server: the button list and every run carry `scope: "mine"`, and a row they did
423
+ not create is refused with 403 - as is a direct call to `withdraw-application`
424
+ on someone else's row, through its `rowLevel` owner rule.
425
+
426
+ Try to skip ahead - copy an admin URL and POST it as a candidate, or POST
427
+ `withdraw` against an already-rejected row - and you get a clean HTTP 409
428
+ `Cannot apply "Withdraw" - current status is "rejected", allowed: submitted, reviewing`.
429
+ **The state machine is enforced server-side; the UI just renders what's legal.**
430
+
431
+ ---
432
+
433
+ ## 9. Employer view (bonus)
434
+
435
+ Add an employer dashboard at `content/pages/employer.md`:
436
+
437
+ ```markdown
438
+ ---
439
+ title: Employer Dashboard
440
+ visibility: employer
441
+ ---
442
+
443
+ ## My Jobs
444
+
445
+ [collection slug="jobs"
446
+ scope="mine"
447
+ display="table"
448
+ fields="title,location,type,salary,postedAt"
449
+ sort="postedAt"
450
+ order="desc"
451
+ cta="archive-job"
452
+ cta-label="Archive"
453
+ cta-confirm="Archive this listing?" /]
454
+
455
+ ## Applications to my jobs
456
+
457
+ [collection slug="applications"
458
+ display="table"
459
+ fields="jobTitle,candidateId,status,submittedAt"
460
+ where_jobId_in="{{ employerJobIds }}"
461
+ sort="submittedAt"
462
+ order="desc" /]
463
+ ```
464
+
465
+ The second block needs cross-collection filtering (applications matching the employer's job ids). This currently
466
+ needs a saved view - define one at `content/views/employer-applications.json` that does the join, then use
467
+ `[view slug="employer-applications" /]`. Full cross-collection joins in `[collection]` is a future enhancement.
468
+
469
+ ---
470
+
471
+ ## Mental model - why this works
472
+
473
+ | Need | Primitive used |
474
+ |-----------------------------|------------------------------------------------------------------|
475
+ | Per-role landing pages | `visibility` (string, or array for sibling roles) |
476
+ | Public job listings | `[collection]` + `api.read: public` |
477
+ | Filtered listings | `where_<field>_<op>` shortcode attributes |
478
+ | Faceted browsing UI | `[collection searchable filterable=... sortable]` (Browser) |
479
+ | Apply button | `[form]` + `settings.actionSlug` |
480
+ | Apply = create link record | Action step `createInCollection` |
481
+ | Job ↔ Application link | `type: "reference"` field with `displayField` + `linkTemplate` |
482
+ | Resume upload | `type: "file"` field with mime/size validation |
483
+ | "My applications" view | `[collection scope="mine"]` + hydration endpoint |
484
+ | Lifecycle transitions | `transition: { field, from }` on actions + `transitions` on shortcode |
485
+ | Row-level security | `access.rowLevel` (`mode: "owner"`) on actions |
486
+ | Audit trail | Form submissions persist in their original collection |
487
+
488
+ No plugin code. Everything is configuration plus Markdown. The same five building blocks (collections, forms,
489
+ actions, shortcodes, roles) compose into bookings, RSVPs, marketplaces, classifieds, internal task trackers, or
490
+ anything else where a public page needs to read structured data, accept input, and create relational records on
491
+ behalf of an identified user.
492
+
493
+ ---
494
+
495
+ ## Reference
496
+
497
+ - `docs/markdown-shortcodes.md` - full `[collection]` and `[form]` syntax
498
+ - `docs/api-reference.md` - filter query params, render-scope endpoint
499
+ - `docs/configuration.md` - page frontmatter (`visibility`, `status`, ...)
500
+ - `server/services/actions.js` - full list of action step types and template variables
501
+ - `server/services/filterEngine.js` - full operator semantics for `where_*` attributes