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,1902 @@
1
+ # Surveys (Pro Plugin) Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Ship a `surveys` Pro plugin that wraps the existing Forms engine with a shared audience, a tokenised invite ledger, editor-triggered sending, a scheduler-ready due-work contract, and per-survey statistics.
6
+
7
+ **Architecture:** Everything lives under `plugins/surveys/`; nothing in `server/` is modified. The plugin owns four collections (audience contacts, audience groups, campaigns, invite ledger) and reuses core *services* - `collections.js`, `forms.js`/`FormLogicEngine`, `email.js` - rather than core routes. A plugin-owned public submit endpoint validates the invite token, stores the answer via `createEntry`, and flips the ledger; anonymity is intrinsic because the contact↔response link is only ever written to the ledger, never to the response row. Charts and scheduling are deferred upstream behind two stable seams (`renderDistribution`, the `/due-work`+`/run` contract).
8
+
9
+ **Tech Stack:** Node 20 ESM, Fastify 5, `node:test` + `app.inject`, Domma client (`Domma.forms.render`, `F.create`, `T.create`, `E.*`, `H.*`, `I.*`).
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md`
12
+
13
+ ---
14
+
15
+ ## Reference patterns (read before starting)
16
+
17
+ - Plugin entry + collections-service usage: `plugins/contacts/plugin.js`
18
+ - Plugin manifest + admin block: `plugins/contacts/plugin.json`
19
+ - Plugin collection schema: `plugins/contacts/collections/user-contacts/schema.json` (note `"slug"` inside the file is the real slug; the directory name is cosmetic; `setupPlugin` auto-seeds via `createCollection`)
20
+ - Public root routes (SSR): `plugins/blog/plugin.public.js` (mounted by `server/server.js:359` with `{settings, auth:{authenticate,requireAdmin,requireVisibility}, config}`)
21
+ - Collections service API: `createEntry(slug, data, meta)`, `getEntry(slug, id)`, `listEntries(slug, {limit,sort,order}) → {entries}`, `updateEntry(slug, id, data)`, `deleteEntry(slug, id)`; entry shape `{id, data:{}, meta:{createdAt,updatedAt}}`
22
+ - Forms service: `readForm(slug)`, `ensureCollectionForForm(form)` from `server/services/forms.js`
23
+ - Form validation engine: `public/js/form-logic-engine.js` (CJS) - methods `evaluateFieldVisibility(field, values)`, `evaluateFieldRequirement(field, values)`, `validateField(field, value, values) → [{message}]`
24
+ - Email: `createTransport(smtp)`, `sendEmail(transport, {from,fromName,to,subject,html,text})` from `server/services/email.js`; SMTP at `getConfig('site').smtp`
25
+ - Permissions: `registerPluginResource(resource, plugin)` / `unregisterPluginResourcesByPlugin(plugin)` from `server/services/permissionRegistry.js`
26
+ - Test style: `tests/routes/api-tokens.test.js` (`node:test`, build a Fastify app, inject auth, `app.inject`)
27
+
28
+ **Run tests with:** `npm test` (runs `node --test --test-concurrency=1 'tests/**/*.test.js'`). Single file: `node --test tests/surveys/<file>.test.js`.
29
+
30
+ **Commit discipline:** commit after every passing step group. Do **not** push (project memory: the user commits/pushes separately - local commits during a feature branch are fine; if unsure, leave staged).
31
+
32
+ ---
33
+
34
+ ## File Structure
35
+
36
+ ```
37
+ plugins/surveys/
38
+ plugin.json manifest + admin (sidebar, routes, views)
39
+ plugin.js authed admin API (mounted /api/plugins/surveys)
40
+ plugin.public.js root public: hosted page, submit, due-work/run
41
+ config.js config defaults (storage adapter, fromName…)
42
+ CLAUDE.md routes, storage, gotchas
43
+ collections/
44
+ survey-contacts/schema.json slug: surveys-contacts
45
+ survey-groups/schema.json slug: surveys-groups
46
+ surveys/schema.json slug: surveys-surveys (campaigns)
47
+ survey-invites/schema.json slug: surveys-invites (ledger)
48
+ lib/
49
+ tokens.js generate / hash / verify readable codes
50
+ ledger.js invite create + state queries (pure-ish over collections)
51
+ audience.js contact + group helpers, import-from-contacts
52
+ campaigns.js survey CRUD helpers + form binding
53
+ submit.js token→store→ledger flip (reuses core services)
54
+ sending.js invite/reminder batch via email.js
55
+ stats.js aggregation + renderDistribution + CSV/JSON
56
+ admin/
57
+ templates/
58
+ surveys.html list view shell
59
+ survey-editor.html editor shell
60
+ audience.html contacts/groups shell
61
+ results.html stats shell
62
+ views/
63
+ surveys.js list (T.create)
64
+ survey-editor.js editor (F.create)
65
+ audience.js contacts + groups + import
66
+ results.js health widgets + renderDistribution + export
67
+ public/
68
+ survey.mjs hosted-page client (Domma.forms.render + token bind)
69
+ survey.css hosted-page styles
70
+ templates/
71
+ survey-page.html SSR shell for /survey/:slug
72
+ tests/surveys/
73
+ tokens.test.js ledger.test.js audience.test.js campaigns.test.js
74
+ submit.test.js sending.test.js stats.test.js due-work.test.js
75
+ ```
76
+
77
+ > **ESM client extension gotcha (project memory):** client modules served to the browser as ES modules must use `.mjs` (package.json stays `commonjs`). Hence `public/survey.mjs`.
78
+
79
+ ---
80
+
81
+ ## Phase 0 - Scaffold
82
+
83
+ ### Task 0.1: Plugin manifest + config + CLAUDE.md
84
+
85
+ **Files:**
86
+ - Create: `plugins/surveys/plugin.json`
87
+ - Create: `plugins/surveys/config.js`
88
+ - Create: `plugins/surveys/CLAUDE.md`
89
+
90
+ - [ ] **Step 1: Write `plugin.json`**
91
+
92
+ ```json
93
+ {
94
+ "name": "surveys",
95
+ "displayName": "Surveys",
96
+ "version": "1.0.0",
97
+ "description": "Targeted surveys over the Forms engine: shared audiences, tokenised invites, reminders, and per-survey statistics.",
98
+ "author": "Darryl Waterhouse",
99
+ "date": "2026-06-26",
100
+ "icon": "clipboard-list",
101
+ "admin": {
102
+ "sidebar": [
103
+ { "id": "surveys", "text": "Surveys", "icon": "clipboard-list", "url": "#/plugins/surveys", "section": "#/plugins/surveys", "permission": "surveys" }
104
+ ],
105
+ "routes": [
106
+ { "path": "/plugins/surveys", "view": "plugin-surveys", "title": "Surveys - Domma CMS" },
107
+ { "path": "/plugins/surveys/new", "view": "plugin-survey-editor", "title": "New Survey - Domma CMS" },
108
+ { "path": "/plugins/surveys/edit/:slug", "view": "plugin-survey-editor", "title": "Edit Survey - Domma CMS" },
109
+ { "path": "/plugins/surveys/audience", "view": "plugin-survey-audience", "title": "Survey Audience - Domma CMS" },
110
+ { "path": "/plugins/surveys/results/:slug", "view": "plugin-survey-results", "title": "Survey Results - Domma CMS" }
111
+ ],
112
+ "views": {
113
+ "plugin-surveys": { "entry": "surveys/admin/views/surveys.js", "exportName": "surveysView" },
114
+ "plugin-survey-editor": { "entry": "surveys/admin/views/survey-editor.js", "exportName": "surveyEditorView" },
115
+ "plugin-survey-audience":{ "entry": "surveys/admin/views/audience.js", "exportName": "audienceView" },
116
+ "plugin-survey-results": { "entry": "surveys/admin/views/results.js", "exportName": "resultsView" }
117
+ }
118
+ }
119
+ }
120
+ ```
121
+
122
+ - [ ] **Step 2: Write `config.js`**
123
+
124
+ ```js
125
+ export default {
126
+ // Pro: Mongo when config/connections.json exists; degrades to file otherwise.
127
+ storage: {adapter: 'mongodb', connection: 'default'},
128
+ // Service token (dcms_...) the external scheduler presents to /due-work + /run.
129
+ // Empty disables the scheduler endpoints (403). Set in config/plugins.json.
130
+ schedulerToken: '',
131
+ fromName: 'Surveys'
132
+ };
133
+ ```
134
+
135
+ - [ ] **Step 3: Write `CLAUDE.md`** documenting: collection slugs (`surveys-contacts`, `surveys-groups`, `surveys-surveys`, `surveys-invites`), the public routes (`/survey/:slug`, `/api/plugins/surveys/submit/:slug`, `/api/plugins/surveys/due-work`, `/api/plugins/surveys/run`), the admin prefix `/api/plugins/surveys`, and gotchas (token hashed not stored; anonymity = link only in ledger; storage degrades to file).
136
+
137
+ - [ ] **Step 4: Commit**
138
+
139
+ ```bash
140
+ git add plugins/surveys/plugin.json plugins/surveys/config.js plugins/surveys/CLAUDE.md
141
+ git commit -m "feat(surveys): scaffold plugin manifest, config, and guide"
142
+ ```
143
+
144
+ ### Task 0.2: Collection schemas
145
+
146
+ **Files:**
147
+ - Create: `plugins/surveys/collections/survey-contacts/schema.json`
148
+ - Create: `plugins/surveys/collections/survey-groups/schema.json`
149
+ - Create: `plugins/surveys/collections/surveys/schema.json`
150
+ - Create: `plugins/surveys/collections/survey-invites/schema.json`
151
+
152
+ - [ ] **Step 1: `survey-contacts/schema.json`**
153
+
154
+ ```json
155
+ {
156
+ "slug": "surveys-contacts",
157
+ "title": "Survey Contacts",
158
+ "description": "Shared survey audience contacts.",
159
+ "plugin": "surveys",
160
+ "storage": {"adapter": "mongodb", "connection": "default"},
161
+ "fields": [
162
+ {"name": "name", "label": "Name", "type": "text", "required": false},
163
+ {"name": "email", "label": "Email", "type": "text", "required": true},
164
+ {"name": "groups","label": "Groups","type": "text", "required": false},
165
+ {"name": "meta", "label": "Meta", "type": "textarea", "required": false}
166
+ ]
167
+ }
168
+ ```
169
+
170
+ - [ ] **Step 2: `survey-groups/schema.json`**
171
+
172
+ ```json
173
+ {
174
+ "slug": "surveys-groups",
175
+ "title": "Survey Groups",
176
+ "description": "Named survey audience groups.",
177
+ "plugin": "surveys",
178
+ "storage": {"adapter": "mongodb", "connection": "default"},
179
+ "fields": [
180
+ {"name": "name", "label": "Name", "type": "text", "required": true},
181
+ {"name": "description", "label": "Description", "type": "textarea", "required": false}
182
+ ]
183
+ }
184
+ ```
185
+
186
+ - [ ] **Step 3: `surveys/schema.json`**
187
+
188
+ ```json
189
+ {
190
+ "slug": "surveys-surveys",
191
+ "title": "Surveys",
192
+ "description": "Survey campaigns.",
193
+ "plugin": "surveys",
194
+ "storage": {"adapter": "mongodb", "connection": "default"},
195
+ "fields": [
196
+ {"name": "slug", "label": "Slug", "type": "text", "required": true},
197
+ {"name": "title", "label": "Title", "type": "text", "required": true},
198
+ {"name": "description", "label": "Description", "type": "textarea", "required": false},
199
+ {"name": "formSlug", "label": "Form", "type": "text", "required": true},
200
+ {"name": "groupIds", "label": "Groups", "type": "text", "required": false},
201
+ {"name": "mode", "label": "Mode", "type": "text", "required": true},
202
+ {"name": "status", "label": "Status", "type": "text", "required": true},
203
+ {"name": "opensAt", "label": "Opens At", "type": "text", "required": false},
204
+ {"name": "closesAt", "label": "Closes At", "type": "text", "required": false},
205
+ {"name": "reminderAfterDays", "label": "Reminder (days)", "type": "text", "required": false},
206
+ {"name": "inviteEmail", "label": "Invite Email", "type": "textarea", "required": false}
207
+ ]
208
+ }
209
+ ```
210
+
211
+ - [ ] **Step 4: `survey-invites/schema.json`**
212
+
213
+ ```json
214
+ {
215
+ "slug": "surveys-invites",
216
+ "title": "Survey Invites",
217
+ "description": "Per-recipient invite ledger (token hashes + status).",
218
+ "plugin": "surveys",
219
+ "storage": {"adapter": "mongodb", "connection": "default"},
220
+ "fields": [
221
+ {"name": "surveyId", "label": "Survey", "type": "text", "required": true},
222
+ {"name": "contactId", "label": "Contact", "type": "text", "required": true},
223
+ {"name": "tokenHash", "label": "Token Hash", "type": "text", "required": true},
224
+ {"name": "status", "label": "Status", "type": "text", "required": true},
225
+ {"name": "sentAt", "label": "Sent At", "type": "text", "required": false},
226
+ {"name": "completedAt", "label": "Completed At","type": "text", "required": false},
227
+ {"name": "responseId", "label": "Response", "type": "text", "required": false}
228
+ ]
229
+ }
230
+ ```
231
+
232
+ - [ ] **Step 5: Commit**
233
+
234
+ ```bash
235
+ git add plugins/surveys/collections
236
+ git commit -m "feat(surveys): collection schemas for contacts, groups, campaigns, invites"
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Phase 1 - Tokens (`lib/tokens.js`)
242
+
243
+ ### Task 1.1: Readable token generate / hash / verify
244
+
245
+ **Files:**
246
+ - Create: `plugins/surveys/lib/tokens.js`
247
+ - Test: `tests/surveys/tokens.test.js`
248
+
249
+ - [ ] **Step 1: Write the failing test**
250
+
251
+ ```js
252
+ import {test} from 'node:test';
253
+ import assert from 'node:assert/strict';
254
+ import {generateToken, hashToken, formatCode, normaliseCode} from '../../plugins/surveys/lib/tokens.js';
255
+
256
+ test('generateToken returns a readable code with no ambiguous chars', () => {
257
+ const t = generateToken();
258
+ assert.match(t, /^[A-Z0-9]{8}$/);
259
+ assert.equal(/[O0I1L]/.test(t), false);
260
+ });
261
+
262
+ test('hashToken is stable and case/format-insensitive via normaliseCode', () => {
263
+ const t = generateToken();
264
+ const a = hashToken(normaliseCode(formatCode(t)));
265
+ const b = hashToken(normaliseCode(t.toLowerCase()));
266
+ assert.equal(a, b);
267
+ assert.equal(a.length, 64); // sha-256 hex
268
+ });
269
+
270
+ test('formatCode groups into XXXX-XXXX, normaliseCode strips it back', () => {
271
+ assert.equal(formatCode('ABCDEFGH'), 'ABCD-EFGH');
272
+ assert.equal(normaliseCode('abcd-efgh'), 'ABCDEFGH');
273
+ });
274
+ ```
275
+
276
+ - [ ] **Step 2: Run to verify it fails**
277
+
278
+ Run: `node --test tests/surveys/tokens.test.js`
279
+ Expected: FAIL - cannot find module `tokens.js`.
280
+
281
+ - [ ] **Step 3: Implement**
282
+
283
+ ```js
284
+ import crypto from 'crypto';
285
+
286
+ const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no O,0,I,1,L
287
+ const LENGTH = 8;
288
+
289
+ /** Generate an 8-char unambiguous uppercase token. */
290
+ export function generateToken() {
291
+ const bytes = crypto.randomBytes(LENGTH);
292
+ let out = '';
293
+ for (let i = 0; i < LENGTH; i++) out += ALPHABET[bytes[i] % ALPHABET.length];
294
+ return out;
295
+ }
296
+
297
+ /** SHA-256 hex of a normalised code. The plaintext is never stored. */
298
+ export function hashToken(code) {
299
+ return crypto.createHash('sha256').update(String(code)).digest('hex');
300
+ }
301
+
302
+ /** Display form: ABCD-EFGH. */
303
+ export function formatCode(token) {
304
+ const t = String(token).toUpperCase();
305
+ return t.length === 8 ? `${t.slice(0, 4)}-${t.slice(4)}` : t;
306
+ }
307
+
308
+ /** Canonical form for hashing/lookup: strip non-alphanumerics, uppercase. */
309
+ export function normaliseCode(input) {
310
+ return String(input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
311
+ }
312
+ ```
313
+
314
+ - [ ] **Step 4: Run to verify pass**
315
+
316
+ Run: `node --test tests/surveys/tokens.test.js`
317
+ Expected: PASS (3 tests).
318
+
319
+ - [ ] **Step 5: Commit**
320
+
321
+ ```bash
322
+ git add plugins/surveys/lib/tokens.js tests/surveys/tokens.test.js
323
+ git commit -m "feat(surveys): readable invite token generate/hash/normalise"
324
+ ```
325
+
326
+ ---
327
+
328
+ ## Phase 2 - Ledger (`lib/ledger.js`)
329
+
330
+ The ledger wraps the `surveys-invites` collection. Tests inject a fake collections service so they run without disk/Mongo.
331
+
332
+ ### Task 2.1: Create invites for a survey + audience
333
+
334
+ **Files:**
335
+ - Create: `plugins/surveys/lib/ledger.js`
336
+ - Test: `tests/surveys/ledger.test.js`
337
+
338
+ - [ ] **Step 1: Write the failing test**
339
+
340
+ ```js
341
+ import {test} from 'node:test';
342
+ import assert from 'node:assert/strict';
343
+ import {createLedger} from '../../plugins/surveys/lib/ledger.js';
344
+ import {normaliseCode} from '../../plugins/surveys/lib/tokens.js';
345
+
346
+ function fakeCollections() {
347
+ const rows = [];
348
+ let n = 0;
349
+ return {
350
+ rows,
351
+ async createEntry(slug, data) { const e = {id: `e${++n}`, data, meta: {}}; rows.push(e); return e; },
352
+ async listEntries() { return {entries: rows}; },
353
+ async getEntry(slug, id) { return rows.find(r => r.id === id) ?? null; },
354
+ async updateEntry(slug, id, data) { const r = rows.find(x => x.id === id); r.data = data; return r; }
355
+ };
356
+ }
357
+
358
+ test('issueInvites creates one pending invite per contact with a hashed token', async () => {
359
+ const col = fakeCollections();
360
+ const ledger = createLedger(col);
361
+ const issued = await ledger.issueInvites('survey1', ['c1', 'c2']);
362
+ assert.equal(issued.length, 2);
363
+ assert.equal(col.rows.length, 2);
364
+ assert.equal(col.rows[0].data.status, 'pending');
365
+ assert.equal(col.rows[0].data.tokenHash.length, 64);
366
+ // plaintext token is returned to the caller (for the email) but not stored
367
+ assert.match(issued[0].token, /^[A-Z0-9]{8}$/);
368
+ assert.equal(col.rows[0].data.token, undefined);
369
+ });
370
+
371
+ test('issueInvites is idempotent - re-issuing skips existing (survey,contact) pairs', async () => {
372
+ const col = fakeCollections();
373
+ const ledger = createLedger(col);
374
+ await ledger.issueInvites('survey1', ['c1']);
375
+ const again = await ledger.issueInvites('survey1', ['c1', 'c2']);
376
+ assert.equal(col.rows.length, 2); // c1 not duplicated
377
+ assert.equal(again.length, 1); // only c2 newly issued
378
+ assert.equal(again[0].contactId, 'c2');
379
+ });
380
+ ```
381
+
382
+ - [ ] **Step 2: Run to verify it fails**
383
+
384
+ Run: `node --test tests/surveys/ledger.test.js`
385
+ Expected: FAIL - cannot find module `ledger.js`.
386
+
387
+ - [ ] **Step 3: Implement**
388
+
389
+ ```js
390
+ import {generateToken, hashToken, normaliseCode} from './tokens.js';
391
+
392
+ const SLUG = 'surveys-invites';
393
+
394
+ /**
395
+ * Ledger over the surveys-invites collection.
396
+ * @param {{createEntry,listEntries,getEntry,updateEntry}} col collections service
397
+ */
398
+ export function createLedger(col) {
399
+ async function invitesForSurvey(surveyId) {
400
+ const {entries} = await col.listEntries(SLUG, {limit: 100000});
401
+ return entries.filter(e => e.data.surveyId === surveyId);
402
+ }
403
+
404
+ /** Issue pending invites for contacts that don't already have one. Returns [{id, contactId, token}]. */
405
+ async function issueInvites(surveyId, contactIds) {
406
+ const existing = await invitesForSurvey(surveyId);
407
+ const seen = new Set(existing.map(e => e.data.contactId));
408
+ const out = [];
409
+ for (const contactId of contactIds) {
410
+ if (seen.has(contactId)) continue;
411
+ const token = generateToken();
412
+ const entry = await col.createEntry(SLUG, {
413
+ surveyId, contactId,
414
+ tokenHash: hashToken(normaliseCode(token)),
415
+ status: 'pending', sentAt: null, completedAt: null, responseId: null
416
+ }, {source: 'surveys'});
417
+ out.push({id: entry.id, contactId, token});
418
+ seen.add(contactId);
419
+ }
420
+ return out;
421
+ }
422
+
423
+ /** Find an invite by submitted code (any format). Null if unknown. */
424
+ async function findByCode(surveyId, code) {
425
+ const hash = hashToken(normaliseCode(code));
426
+ const invites = await invitesForSurvey(surveyId);
427
+ return invites.find(e => e.data.tokenHash === hash) ?? null;
428
+ }
429
+
430
+ /** Mark sent / bounced. */
431
+ async function markSent(inviteId, ok, nowIso) {
432
+ const entry = await col.getEntry(SLUG, inviteId);
433
+ if (!entry) return null;
434
+ return col.updateEntry(SLUG, inviteId, {
435
+ ...entry.data,
436
+ status: ok ? 'sent' : 'bounced',
437
+ sentAt: ok ? nowIso : entry.data.sentAt
438
+ });
439
+ }
440
+
441
+ /** Mark completed with the stored response id. */
442
+ async function markCompleted(inviteId, responseId, nowIso) {
443
+ const entry = await col.getEntry(SLUG, inviteId);
444
+ if (!entry) return null;
445
+ return col.updateEntry(SLUG, inviteId, {
446
+ ...entry.data, status: 'completed', completedAt: nowIso, responseId: responseId ?? null
447
+ });
448
+ }
449
+
450
+ return {invitesForSurvey, issueInvites, findByCode, markSent, markCompleted};
451
+ }
452
+ ```
453
+
454
+ - [ ] **Step 4: Run to verify pass**
455
+
456
+ Run: `node --test tests/surveys/ledger.test.js`
457
+ Expected: PASS (2 tests).
458
+
459
+ - [ ] **Step 5: Commit**
460
+
461
+ ```bash
462
+ git add plugins/surveys/lib/ledger.js tests/surveys/ledger.test.js
463
+ git commit -m "feat(surveys): invite ledger - idempotent issue + status transitions"
464
+ ```
465
+
466
+ ### Task 2.2: Outstanding + due-for-reminder queries
467
+
468
+ **Files:**
469
+ - Modify: `plugins/surveys/lib/ledger.js`
470
+ - Test: `tests/surveys/ledger.test.js`
471
+
472
+ - [ ] **Step 1: Add failing tests**
473
+
474
+ ```js
475
+ test('outstanding excludes completed and bounced', async () => {
476
+ const col = fakeCollections();
477
+ const ledger = createLedger(col);
478
+ const [i1, i2] = await ledger.issueInvites('s', ['c1', 'c2']);
479
+ await ledger.markCompleted(i1.id, 'r1', '2026-06-26T00:00:00Z');
480
+ const out = await ledger.outstanding('s');
481
+ assert.deepEqual(out.map(e => e.data.contactId), ['c2']);
482
+ });
483
+
484
+ test('dueForReminder = sent, not completed, sentAt older than N days', async () => {
485
+ const col = fakeCollections();
486
+ const ledger = createLedger(col);
487
+ const [i1] = await ledger.issueInvites('s', ['c1']);
488
+ await ledger.markSent(i1.id, true, '2026-06-01T00:00:00Z');
489
+ const due = await ledger.dueForReminder('s', 7, new Date('2026-06-26T00:00:00Z'));
490
+ assert.equal(due.length, 1);
491
+ const notDue = await ledger.dueForReminder('s', 7, new Date('2026-06-03T00:00:00Z'));
492
+ assert.equal(notDue.length, 0);
493
+ });
494
+ ```
495
+
496
+ - [ ] **Step 2: Run to verify fail**
497
+
498
+ Run: `node --test tests/surveys/ledger.test.js`
499
+ Expected: FAIL - `ledger.outstanding is not a function`.
500
+
501
+ - [ ] **Step 3: Implement (add inside `createLedger`, include in the returned object)**
502
+
503
+ ```js
504
+ async function outstanding(surveyId) {
505
+ const invites = await invitesForSurvey(surveyId);
506
+ return invites.filter(e => e.data.status !== 'completed' && e.data.status !== 'bounced');
507
+ }
508
+
509
+ async function dueForReminder(surveyId, afterDays, now) {
510
+ const cutoff = now.getTime() - Number(afterDays) * 86400000;
511
+ const invites = await invitesForSurvey(surveyId);
512
+ return invites.filter(e =>
513
+ e.data.status === 'sent' &&
514
+ e.data.sentAt && new Date(e.data.sentAt).getTime() <= cutoff
515
+ );
516
+ }
517
+ ```
518
+
519
+ Add `outstanding, dueForReminder` to the `return {...}`.
520
+
521
+ - [ ] **Step 4: Run to verify pass**
522
+
523
+ Run: `node --test tests/surveys/ledger.test.js`
524
+ Expected: PASS (4 tests).
525
+
526
+ - [ ] **Step 5: Commit**
527
+
528
+ ```bash
529
+ git add plugins/surveys/lib/ledger.js tests/surveys/ledger.test.js
530
+ git commit -m "feat(surveys): ledger outstanding + due-for-reminder queries"
531
+ ```
532
+
533
+ ---
534
+
535
+ ## Phase 3 - Submit pipeline (`lib/submit.js`)
536
+
537
+ The heart of the feature. Reuses core services; the contact↔response link is written only to the ledger.
538
+
539
+ ### Task 3.1: `processSurveySubmission`
540
+
541
+ **Files:**
542
+ - Create: `plugins/surveys/lib/submit.js`
543
+ - Test: `tests/surveys/submit.test.js`
544
+
545
+ - [ ] **Step 1: Write the failing test** (inject all collaborators - no disk, no SMTP)
546
+
547
+ ```js
548
+ import {test} from 'node:test';
549
+ import assert from 'node:assert/strict';
550
+ import {processSurveySubmission} from '../../plugins/surveys/lib/submit.js';
551
+ import {createLedger} from '../../plugins/surveys/lib/ledger.js';
552
+ import {generateToken, hashToken, normaliseCode} from '../../plugins/surveys/lib/tokens.js';
553
+
554
+ function harness() {
555
+ const rows = [];
556
+ let n = 0;
557
+ const col = {
558
+ rows,
559
+ async createEntry(slug, data) { const e = {id: `${slug}-${++n}`, data, meta: {}}; rows.push({slug, ...e}); return e; },
560
+ async listEntries() { return {entries: rows.filter(r => r.slug === 'surveys-invites')}; },
561
+ async getEntry(slug, id) { return rows.find(r => r.id === id) ?? null; },
562
+ async updateEntry(slug, id, data) { const r = rows.find(x => x.id === id); r.data = data; return r; }
563
+ };
564
+ const survey = {id: 'sv1', data: {slug: 'sat', formSlug: 'sat-form', mode: 'identified', status: 'open'}};
565
+ const form = {slug: 'sat-form', title: 'Satisfaction', fields: [{name: 'q1', label: 'Q1', type: 'text', required: true}], actions: {}};
566
+ const deps = {
567
+ col,
568
+ ledger: createLedger(col),
569
+ getSurveyBySlug: async () => survey,
570
+ readForm: async () => form,
571
+ getCollection: async () => ({slug: 'sat-form'}),
572
+ sendCompletionEmail: async () => {},
573
+ now: () => '2026-06-26T00:00:00Z'
574
+ };
575
+ return {col, survey, form, deps};
576
+ }
577
+
578
+ test('valid token + identified mode stores answer and links only on the ledger', async () => {
579
+ const {col, deps} = harness();
580
+ const [invite] = await deps.ledger.issueInvites('sv1', ['c1']);
581
+ const res = await processSurveySubmission('sat', invite.token, {q1: 'Great'}, deps);
582
+ assert.equal(res.ok, true);
583
+ const response = col.rows.find(r => r.slug === 'sat-form');
584
+ assert.equal(response.data.q1, 'Great');
585
+ assert.equal(response.data.contactId, undefined); // never on the response
586
+ assert.equal(response.data.survey_token, undefined); // token never persisted
587
+ const led = col.rows.find(r => r.slug === 'surveys-invites');
588
+ assert.equal(led.data.status, 'completed');
589
+ assert.equal(led.data.responseId, response.id); // link lives on the ledger
590
+ });
591
+
592
+ test('anonymous mode flips the ledger but stores no contactId anywhere', async () => {
593
+ const {col, deps} = harness();
594
+ deps.getSurveyBySlug = async () => ({id: 'sv1', data: {slug: 'sat', formSlug: 'sat-form', mode: 'anonymous', status: 'open'}});
595
+ const [invite] = await deps.ledger.issueInvites('sv1', ['c1']);
596
+ await processSurveySubmission('sat', invite.token, {q1: 'Fine'}, deps);
597
+ const led = col.rows.find(r => r.slug === 'surveys-invites');
598
+ assert.equal(led.data.status, 'completed');
599
+ assert.equal(led.data.responseId, null); // anonymous: not even on the ledger
600
+ });
601
+
602
+ test('unknown token is rejected', async () => {
603
+ const {deps} = harness();
604
+ await deps.ledger.issueInvites('sv1', ['c1']);
605
+ await assert.rejects(() => processSurveySubmission('sat', 'WRONGCOD', {q1: 'x'}, deps), /invalid/i);
606
+ });
607
+
608
+ test('already-completed token is rejected', async () => {
609
+ const {deps} = harness();
610
+ const [invite] = await deps.ledger.issueInvites('sv1', ['c1']);
611
+ await processSurveySubmission('sat', invite.token, {q1: 'a'}, deps);
612
+ await assert.rejects(() => processSurveySubmission('sat', invite.token, {q1: 'b'}, deps), /already/i);
613
+ });
614
+
615
+ test('closed survey is rejected', async () => {
616
+ const {deps} = harness();
617
+ deps.getSurveyBySlug = async () => ({id: 'sv1', data: {slug: 'sat', formSlug: 'sat-form', mode: 'identified', status: 'closed'}});
618
+ const [invite] = await deps.ledger.issueInvites('sv1', ['c1']);
619
+ await assert.rejects(() => processSurveySubmission('sat', invite.token, {q1: 'a'}, deps), /closed/i);
620
+ });
621
+
622
+ test('required-field validation rejects empty answers', async () => {
623
+ const {deps} = harness();
624
+ const [invite] = await deps.ledger.issueInvites('sv1', ['c1']);
625
+ await assert.rejects(() => processSurveySubmission('sat', invite.token, {q1: ''}, deps), /required/i);
626
+ });
627
+ ```
628
+
629
+ - [ ] **Step 2: Run to verify it fails**
630
+
631
+ Run: `node --test tests/surveys/submit.test.js`
632
+ Expected: FAIL - cannot find module `submit.js`.
633
+
634
+ - [ ] **Step 3: Implement**
635
+
636
+ ```js
637
+ import {createRequire} from 'module';
638
+
639
+ const require = createRequire(import.meta.url);
640
+ const FormLogicEngine = require('../../../public/js/form-logic-engine.js');
641
+
642
+ class SurveyError extends Error {
643
+ constructor(message, code = 400) { super(message); this.statusCode = code; }
644
+ }
645
+
646
+ function withinWindow(data, now) {
647
+ const t = new Date(now).getTime();
648
+ if (data.opensAt && t < new Date(data.opensAt).getTime()) return false;
649
+ if (data.closesAt && t > new Date(data.closesAt).getTime()) return false;
650
+ return true;
651
+ }
652
+
653
+ /** Validate visible fields with the same engine core uses. Throws SurveyError. */
654
+ function validate(form, body) {
655
+ const values = {};
656
+ for (const f of form.fields || []) {
657
+ if (['page-break', 'spacer'].includes(f.type)) continue;
658
+ const v = body[f.name];
659
+ values[f.name] = typeof v === 'string' ? v.trim() : (v ?? '');
660
+ }
661
+ const missing = [];
662
+ const errors = [];
663
+ for (const f of form.fields || []) {
664
+ if (['page-break', 'spacer'].includes(f.type)) continue;
665
+ if (FormLogicEngine.evaluateFieldVisibility(f, values) === 'hidden') continue;
666
+ const val = values[f.name];
667
+ const empty = !val?.toString().trim();
668
+ if (FormLogicEngine.evaluateFieldRequirement(f, values) && empty) missing.push(f.label || f.name);
669
+ else if (!empty) {
670
+ const e = FormLogicEngine.validateField(f, val, values);
671
+ if (e.length) errors.push(e[0].message);
672
+ }
673
+ }
674
+ if (missing.length || errors.length) {
675
+ const parts = [];
676
+ if (missing.length) parts.push(`Required fields missing: ${missing.join(', ')}`);
677
+ if (errors.length) parts.push(errors.join('; '));
678
+ throw new SurveyError(`${parts.join('. ')}.`);
679
+ }
680
+ return values;
681
+ }
682
+
683
+ /**
684
+ * Process a tokenised survey submission.
685
+ * @param {string} surveySlug
686
+ * @param {string} code submitted invite code (any format)
687
+ * @param {object} body answer values keyed by field name
688
+ * @param {object} deps {col, ledger, getSurveyBySlug, readForm, getCollection, sendCompletionEmail, now}
689
+ * @returns {Promise<{ok:true, message:string}>}
690
+ */
691
+ export async function processSurveySubmission(surveySlug, code, body, deps) {
692
+ const {col, ledger, getSurveyBySlug, readForm, getCollection, sendCompletionEmail, now} = deps;
693
+
694
+ const survey = await getSurveyBySlug(surveySlug);
695
+ if (!survey) throw new SurveyError('Survey not found.', 404);
696
+ const s = survey.data;
697
+ if (s.status !== 'open') throw new SurveyError('This survey has closed.', 410);
698
+ if (!withinWindow(s, now())) throw new SurveyError('This survey has closed.', 410);
699
+
700
+ const invite = await ledger.findByCode(survey.id, code);
701
+ if (!invite) throw new SurveyError('That code is invalid for this survey.', 403);
702
+ if (invite.data.status === 'completed') throw new SurveyError('This survey has already been completed for that code.', 409);
703
+
704
+ const form = await readForm(s.formSlug);
705
+ const values = validate(form, body);
706
+
707
+ // Store the answer in the form's own submission collection.
708
+ const targetSlug = s.formSlug;
709
+ const col0 = await getCollection(targetSlug);
710
+ if (!col0) throw new SurveyError('Survey form is not configured correctly.', 500);
711
+ const data = {};
712
+ for (const f of form.fields || []) {
713
+ if (['page-break', 'spacer'].includes(f.type)) continue;
714
+ if (values[f.name] !== undefined) data[f.name] = values[f.name];
715
+ }
716
+ // NOTE: contactId / token are deliberately NOT written here - anonymity is intrinsic.
717
+ const entry = await col.createEntry(targetSlug, data, {source: `survey:${surveySlug}`});
718
+
719
+ // Flip the ledger. Identified mode keeps the response link; anonymous discards it.
720
+ const responseId = s.mode === 'identified' ? entry.id : null;
721
+ await ledger.markCompleted(invite.id, responseId, now());
722
+
723
+ // Completion notification reuses the form's own email action.
724
+ await sendCompletionEmail(form, data).catch(() => {});
725
+
726
+ return {ok: true, message: form.settings?.successMessage || 'Thank you - your response has been recorded.'};
727
+ }
728
+
729
+ export {SurveyError};
730
+ ```
731
+
732
+ - [ ] **Step 4: Run to verify pass**
733
+
734
+ Run: `node --test tests/surveys/submit.test.js`
735
+ Expected: PASS (6 tests).
736
+
737
+ - [ ] **Step 5: Commit**
738
+
739
+ ```bash
740
+ git add plugins/surveys/lib/submit.js tests/surveys/submit.test.js
741
+ git commit -m "feat(surveys): tokenised submit pipeline with intrinsic anonymity"
742
+ ```
743
+
744
+ ---
745
+
746
+ ## Phase 4 - Audience & campaign helpers
747
+
748
+ ### Task 4.1: Audience helpers (`lib/audience.js`)
749
+
750
+ **Files:**
751
+ - Create: `plugins/surveys/lib/audience.js`
752
+ - Test: `tests/surveys/audience.test.js`
753
+
754
+ - [ ] **Step 1: Write the failing test**
755
+
756
+ ```js
757
+ import {test} from 'node:test';
758
+ import assert from 'node:assert/strict';
759
+ import {createAudience} from '../../plugins/surveys/lib/audience.js';
760
+
761
+ function fakeCol(seed = []) {
762
+ const rows = seed.map((d, i) => ({slug: d._slug, id: d.id ?? `r${i}`, data: d, meta: {}}));
763
+ let n = rows.length;
764
+ return {
765
+ rows,
766
+ async listEntries(slug) { return {entries: rows.filter(r => r.slug === slug)}; },
767
+ async createEntry(slug, data) { const e = {slug, id: `r${++n}`, data, meta: {}}; rows.push(e); return e; },
768
+ async getEntry(slug, id) { return rows.find(r => r.id === id) ?? null; }
769
+ };
770
+ }
771
+
772
+ test('contactIdsForGroups returns contacts whose groups intersect the requested groups', async () => {
773
+ const col = fakeCol([
774
+ {_slug: 'surveys-contacts', id: 'a', email: 'a@x', groups: ['staff']},
775
+ {_slug: 'surveys-contacts', id: 'b', email: 'b@x', groups: ['vips']},
776
+ {_slug: 'surveys-contacts', id: 'c', email: 'c@x', groups: ['staff', 'vips']}
777
+ ]);
778
+ const aud = createAudience(col);
779
+ const ids = await aud.contactIdsForGroups(['vips']);
780
+ assert.deepEqual(ids.sort(), ['b', 'c']);
781
+ });
782
+
783
+ test('importFromContacts copies name/email/groups and skips entries without email', async () => {
784
+ const col = fakeCol();
785
+ const aud = createAudience(col);
786
+ const source = [{name: 'Jo', email: 'jo@x', groups: ['team']}, {name: 'NoEmail'}];
787
+ const {imported} = await aud.importFromContacts(source);
788
+ assert.equal(imported, 1);
789
+ const stored = col.rows.filter(r => r.slug === 'surveys-contacts');
790
+ assert.equal(stored[0].data.email, 'jo@x');
791
+ });
792
+ ```
793
+
794
+ - [ ] **Step 2: Run to verify it fails**
795
+
796
+ Run: `node --test tests/surveys/audience.test.js`
797
+ Expected: FAIL - cannot find module `audience.js`.
798
+
799
+ - [ ] **Step 3: Implement**
800
+
801
+ ```js
802
+ const C = 'surveys-contacts';
803
+
804
+ function parseGroups(raw) {
805
+ if (Array.isArray(raw)) return raw;
806
+ if (typeof raw === 'string') { try { return JSON.parse(raw); } catch { return raw ? [raw] : []; } }
807
+ return [];
808
+ }
809
+
810
+ export function createAudience(col) {
811
+ async function listContacts() {
812
+ const {entries} = await col.listEntries(C, {limit: 100000});
813
+ return entries.map(e => ({id: e.id, name: e.data.name ?? '', email: e.data.email ?? '', groups: parseGroups(e.data.groups)}));
814
+ }
815
+
816
+ async function contactIdsForGroups(groupNames) {
817
+ const want = new Set(groupNames);
818
+ const contacts = await listContacts();
819
+ return contacts.filter(c => c.groups.some(g => want.has(g))).map(c => c.id);
820
+ }
821
+
822
+ async function importFromContacts(sourceContacts) {
823
+ let imported = 0;
824
+ for (const c of sourceContacts) {
825
+ const email = (c.email ?? '').trim();
826
+ if (!email) continue;
827
+ await col.createEntry(C, {
828
+ name: (c.name ?? '').trim(),
829
+ email,
830
+ groups: parseGroups(c.groups)
831
+ }, {source: 'import-contacts'});
832
+ imported++;
833
+ }
834
+ return {imported};
835
+ }
836
+
837
+ return {listContacts, contactIdsForGroups, importFromContacts};
838
+ }
839
+ ```
840
+
841
+ - [ ] **Step 4: Run to verify pass**
842
+
843
+ Run: `node --test tests/surveys/audience.test.js`
844
+ Expected: PASS (2 tests).
845
+
846
+ - [ ] **Step 5: Commit**
847
+
848
+ ```bash
849
+ git add plugins/surveys/lib/audience.js tests/surveys/audience.test.js
850
+ git commit -m "feat(surveys): audience helpers - group resolution + contacts import"
851
+ ```
852
+
853
+ ### Task 4.2: Campaign helpers (`lib/campaigns.js`)
854
+
855
+ **Files:**
856
+ - Create: `plugins/surveys/lib/campaigns.js`
857
+ - Test: `tests/surveys/campaigns.test.js`
858
+
859
+ - [ ] **Step 1: Write the failing test**
860
+
861
+ ```js
862
+ import {test} from 'node:test';
863
+ import assert from 'node:assert/strict';
864
+ import {createCampaigns} from '../../plugins/surveys/lib/campaigns.js';
865
+
866
+ function fakeCol() {
867
+ const rows = [];
868
+ let n = 0;
869
+ return {
870
+ rows,
871
+ async listEntries() { return {entries: rows}; },
872
+ async createEntry(slug, data) { const e = {id: `s${++n}`, data, meta: {}}; rows.push(e); return e; },
873
+ async getEntry(slug, id) { return rows.find(r => r.id === id) ?? null; },
874
+ async updateEntry(slug, id, data) { const r = rows.find(x => x.id === id); r.data = data; return r; }
875
+ };
876
+ }
877
+
878
+ test('createSurvey normalises mode/status and rejects a duplicate slug', async () => {
879
+ const c = createCampaigns(fakeCol());
880
+ const s = await c.createSurvey({slug: 'sat', title: 'Sat', formSlug: 'f'});
881
+ assert.equal(s.data.mode, 'identified');
882
+ assert.equal(s.data.status, 'draft');
883
+ await assert.rejects(() => c.createSurvey({slug: 'sat', title: 'Dup', formSlug: 'f'}), /exists/i);
884
+ });
885
+
886
+ test('getBySlug finds a survey by its data.slug', async () => {
887
+ const c = createCampaigns(fakeCol());
888
+ await c.createSurvey({slug: 'sat', title: 'Sat', formSlug: 'f', mode: 'anonymous'});
889
+ const found = await c.getBySlug('sat');
890
+ assert.equal(found.data.mode, 'anonymous');
891
+ });
892
+ ```
893
+
894
+ - [ ] **Step 2: Run to verify it fails**
895
+
896
+ Run: `node --test tests/surveys/campaigns.test.js`
897
+ Expected: FAIL - cannot find module `campaigns.js`.
898
+
899
+ - [ ] **Step 3: Implement**
900
+
901
+ ```js
902
+ const S = 'surveys-surveys';
903
+ const MODES = new Set(['identified', 'anonymous']);
904
+ const STATUSES = new Set(['draft', 'open', 'closed']);
905
+
906
+ export function createCampaigns(col) {
907
+ async function all() { const {entries} = await col.listEntries(S, {limit: 100000}); return entries; }
908
+ async function getBySlug(slug) { return (await all()).find(e => e.data.slug === slug) ?? null; }
909
+
910
+ async function createSurvey(input) {
911
+ if (!input.slug || !input.title || !input.formSlug) throw new Error('slug, title and formSlug are required');
912
+ if (await getBySlug(input.slug)) throw new Error(`A survey with slug "${input.slug}" already exists`);
913
+ return col.createEntry(S, {
914
+ slug: input.slug,
915
+ title: input.title,
916
+ description: input.description ?? '',
917
+ formSlug: input.formSlug,
918
+ groupIds: Array.isArray(input.groupIds) ? input.groupIds : [],
919
+ mode: MODES.has(input.mode) ? input.mode : 'identified',
920
+ status: STATUSES.has(input.status) ? input.status : 'draft',
921
+ opensAt: input.opensAt ?? null,
922
+ closesAt: input.closesAt ?? null,
923
+ reminderAfterDays: Number(input.reminderAfterDays) || 7,
924
+ inviteEmail: input.inviteEmail ?? {subject: '', body: ''}
925
+ }, {source: 'admin'});
926
+ }
927
+
928
+ async function updateSurvey(id, patch) {
929
+ const entry = await col.getEntry(S, id);
930
+ if (!entry) throw new Error('Survey not found');
931
+ const merged = {...entry.data};
932
+ for (const k of ['title', 'description', 'formSlug', 'groupIds', 'opensAt', 'closesAt', 'reminderAfterDays', 'inviteEmail']) {
933
+ if (k in patch) merged[k] = patch[k];
934
+ }
935
+ if (patch.mode && MODES.has(patch.mode)) merged.mode = patch.mode;
936
+ if (patch.status && STATUSES.has(patch.status)) merged.status = patch.status;
937
+ return col.updateEntry(S, id, merged);
938
+ }
939
+
940
+ return {all, getBySlug, createSurvey, updateSurvey};
941
+ }
942
+ ```
943
+
944
+ - [ ] **Step 4: Run to verify pass**
945
+
946
+ Run: `node --test tests/surveys/campaigns.test.js`
947
+ Expected: PASS (2 tests).
948
+
949
+ - [ ] **Step 5: Commit**
950
+
951
+ ```bash
952
+ git add plugins/surveys/lib/campaigns.js tests/surveys/campaigns.test.js
953
+ git commit -m "feat(surveys): campaign helpers - create/update/getBySlug with validation"
954
+ ```
955
+
956
+ ---
957
+
958
+ ## Phase 5 - Sending (`lib/sending.js`)
959
+
960
+ ### Task 5.1: Build invite emails + batch send
961
+
962
+ **Files:**
963
+ - Create: `plugins/surveys/lib/sending.js`
964
+ - Test: `tests/surveys/sending.test.js`
965
+
966
+ - [ ] **Step 1: Write the failing test** (inject a fake transport + ledger)
967
+
968
+ ```js
969
+ import {test} from 'node:test';
970
+ import assert from 'node:assert/strict';
971
+ import {renderInvite, sendBatch} from '../../plugins/surveys/lib/sending.js';
972
+
973
+ test('renderInvite substitutes {{name}} and {{link}}', () => {
974
+ const out = renderInvite({subject: 'Survey for {{name}}', body: 'Go: {{link}}'}, {name: 'Jo'}, 'https://x/survey/sat?t=ABCD-EFGH');
975
+ assert.equal(out.subject, 'Survey for Jo');
976
+ assert.match(out.html, /https:\/\/x\/survey\/sat\?t=ABCD-EFGH/);
977
+ });
978
+
979
+ test('sendBatch emails each recipient and reports per-recipient outcome', async () => {
980
+ const sent = [];
981
+ const fakeTransport = {};
982
+ const deps = {
983
+ sendEmail: async (_t, msg) => { if (msg.to === 'bad@x') throw new Error('bounce'); sent.push(msg.to); },
984
+ transport: fakeTransport, from: 'noreply@x', fromName: 'Surveys',
985
+ baseUrl: 'https://x', surveySlug: 'sat'
986
+ };
987
+ const recipients = [
988
+ {invite: {id: 'i1'}, contact: {name: 'A', email: 'a@x'}, token: 'AAAAAAAA'},
989
+ {invite: {id: 'i2'}, contact: {name: 'B', email: 'bad@x'}, token: 'BBBBBBBB'}
990
+ ];
991
+ const results = await sendBatch(recipients, {subject: 'Hi {{name}}', body: '{{link}}'}, deps);
992
+ assert.deepEqual(sent, ['a@x']);
993
+ assert.deepEqual(results.map(r => [r.inviteId, r.ok]), [['i1', true], ['i2', false]]);
994
+ });
995
+ ```
996
+
997
+ - [ ] **Step 2: Run to verify it fails**
998
+
999
+ Run: `node --test tests/surveys/sending.test.js`
1000
+ Expected: FAIL - cannot find module `sending.js`.
1001
+
1002
+ - [ ] **Step 3: Implement**
1003
+
1004
+ ```js
1005
+ import {formatCode} from './tokens.js';
1006
+
1007
+ function escapeHtml(s) {
1008
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1009
+ }
1010
+
1011
+ /** Render one invite's subject + html from a template and a substitution context. */
1012
+ export function renderInvite(template, contact, link) {
1013
+ const sub = (str) => String(str ?? '')
1014
+ .replaceAll('{{name}}', contact.name || 'there')
1015
+ .replaceAll('{{link}}', link);
1016
+ const subject = sub(template.subject || 'You are invited to a survey');
1017
+ const bodyText = sub(template.body || 'Please take our survey: {{link}}');
1018
+ const html = `<p>${escapeHtml(bodyText).replace(/\n/g, '<br>')}</p>`;
1019
+ return {subject, text: bodyText, html};
1020
+ }
1021
+
1022
+ /**
1023
+ * Send to each recipient; never fails wholesale. Returns [{inviteId, ok}].
1024
+ * @param {Array<{invite,contact,token}>} recipients
1025
+ * @param {{subject,body}} template
1026
+ * @param {object} deps {sendEmail, transport, from, fromName, baseUrl, surveySlug}
1027
+ */
1028
+ export async function sendBatch(recipients, template, deps) {
1029
+ const {sendEmail, transport, from, fromName, baseUrl, surveySlug} = deps;
1030
+ const results = [];
1031
+ for (const r of recipients) {
1032
+ const link = `${baseUrl}/survey/${surveySlug}?t=${formatCode(r.token)}`;
1033
+ const msg = renderInvite(template, r.contact, link);
1034
+ try {
1035
+ await sendEmail(transport, {from, fromName, to: r.contact.email, subject: msg.subject, html: msg.html, text: msg.text});
1036
+ results.push({inviteId: r.invite.id, ok: true});
1037
+ } catch {
1038
+ results.push({inviteId: r.invite.id, ok: false});
1039
+ }
1040
+ }
1041
+ return results;
1042
+ }
1043
+ ```
1044
+
1045
+ > **Reminder tokens:** reminders reuse the *original* invite token (it is regenerable only at issue time; the plaintext is not stored). The send flow therefore re-issues a token only for brand-new invites; reminders to already-`sent` invites must carry a token the recipient already holds. **Decision:** reminder emails link to `/survey/<slug>` **without** `?t=` and instruct the recipient to use the code from their first email (the manual-entry box accepts it). This avoids storing plaintext tokens. Document this in `CLAUDE.md`.
1046
+
1047
+ - [ ] **Step 4: Run to verify pass**
1048
+
1049
+ Run: `node --test tests/surveys/sending.test.js`
1050
+ Expected: PASS (2 tests).
1051
+
1052
+ - [ ] **Step 5: Commit**
1053
+
1054
+ ```bash
1055
+ git add plugins/surveys/lib/sending.js tests/surveys/sending.test.js
1056
+ git commit -m "feat(surveys): invite rendering + non-fatal batch send"
1057
+ ```
1058
+
1059
+ ---
1060
+
1061
+ ## Phase 6 - Stats (`lib/stats.js`)
1062
+
1063
+ ### Task 6.1: Aggregation + renderDistribution + export
1064
+
1065
+ **Files:**
1066
+ - Create: `plugins/surveys/lib/stats.js`
1067
+ - Test: `tests/surveys/stats.test.js`
1068
+
1069
+ - [ ] **Step 1: Write the failing test**
1070
+
1071
+ ```js
1072
+ import {test} from 'node:test';
1073
+ import assert from 'node:assert/strict';
1074
+ import {aggregateField, renderDistribution, toCsv, campaignHealth} from '../../plugins/surveys/lib/stats.js';
1075
+
1076
+ test('aggregateField counts choice distribution with percentages', () => {
1077
+ const field = {name: 'colour', label: 'Colour', type: 'select'};
1078
+ const rows = [{colour: 'red'}, {colour: 'red'}, {colour: 'blue'}];
1079
+ const dist = aggregateField(field, rows);
1080
+ const red = dist.items.find(i => i.label === 'red');
1081
+ assert.equal(red.value, 2);
1082
+ assert.equal(red.pct, 67); // rounded
1083
+ });
1084
+
1085
+ test('aggregateField on rating returns an average', () => {
1086
+ const field = {name: 'score', label: 'Score', type: 'rating'};
1087
+ const rows = [{score: '4'}, {score: '2'}];
1088
+ const dist = aggregateField(field, rows);
1089
+ assert.equal(dist.average, 3);
1090
+ });
1091
+
1092
+ test('renderDistribution emits one bar per item with width set from pct', () => {
1093
+ const html = renderDistribution([{label: 'red', value: 2, pct: 67}]);
1094
+ assert.match(html, /67%/);
1095
+ assert.match(html, /red/);
1096
+ });
1097
+
1098
+ test('campaignHealth computes response rate', () => {
1099
+ const invites = [{data: {status: 'completed'}}, {data: {status: 'sent'}}, {data: {status: 'pending'}}];
1100
+ const h = campaignHealth(invites);
1101
+ assert.equal(h.invited, 3);
1102
+ assert.equal(h.completed, 1);
1103
+ assert.equal(h.responseRate, 33);
1104
+ });
1105
+
1106
+ test('toCsv escapes commas and quotes', () => {
1107
+ const csv = toCsv([{a: 'x,y', b: 'he said "hi"'}], ['a', 'b']);
1108
+ assert.match(csv.split('\n')[1], /"x,y","he said ""hi"""/);
1109
+ });
1110
+ ```
1111
+
1112
+ - [ ] **Step 2: Run to verify it fails**
1113
+
1114
+ Run: `node --test tests/surveys/stats.test.js`
1115
+ Expected: FAIL - cannot find module `stats.js`.
1116
+
1117
+ - [ ] **Step 3: Implement**
1118
+
1119
+ ```js
1120
+ const CHOICE = new Set(['select', 'radio', 'checkbox', 'dropdown']);
1121
+ const RATING = new Set(['rating', 'number', 'range']);
1122
+
1123
+ /** Aggregate one field across response rows into a normalised distribution. */
1124
+ export function aggregateField(field, rows) {
1125
+ const label = field.label || field.name;
1126
+ if (RATING.has(field.type)) {
1127
+ const nums = rows.map(r => Number(r[field.name])).filter(n => !Number.isNaN(n));
1128
+ const average = nums.length ? Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 100) / 100 : 0;
1129
+ const counts = tally(rows.map(r => String(r[field.name] ?? '')).filter(Boolean));
1130
+ return {field: field.name, label, type: 'rating', average, items: toItems(counts)};
1131
+ }
1132
+ if (CHOICE.has(field.type)) {
1133
+ const counts = tally(rows.flatMap(r => Array.isArray(r[field.name]) ? r[field.name] : [r[field.name]]).filter(Boolean));
1134
+ return {field: field.name, label, type: 'choice', items: toItems(counts)};
1135
+ }
1136
+ // free text - list, do not chart
1137
+ return {field: field.name, label, type: 'text', responses: rows.map(r => r[field.name]).filter(Boolean)};
1138
+ }
1139
+
1140
+ function tally(values) {
1141
+ const m = new Map();
1142
+ for (const v of values) m.set(v, (m.get(v) || 0) + 1);
1143
+ return m;
1144
+ }
1145
+
1146
+ function toItems(counts) {
1147
+ const total = [...counts.values()].reduce((a, b) => a + b, 0) || 1;
1148
+ return [...counts.entries()].map(([k, v]) => ({label: String(k), value: v, pct: Math.round((v / total) * 100)}));
1149
+ }
1150
+
1151
+ /**
1152
+ * THE CHARTS SEAM. Input shape [{label,value,pct}] is the exact contract a
1153
+ * future Domma.charts() call will accept. Swap this body for Domma.charts(...)
1154
+ * when the framework component lands. See domma-charts-upstream-proposal.md.
1155
+ */
1156
+ export function renderDistribution(items) {
1157
+ return `<div class="survey-dist">` + items.map(i =>
1158
+ `<div class="survey-dist-row"><span class="survey-dist-label">${escapeHtml(i.label)}</span>` +
1159
+ `<span class="survey-dist-bar"><span class="survey-dist-fill" style="width:${i.pct}%"></span></span>` +
1160
+ `<span class="survey-dist-val">${i.value} (${i.pct}%)</span></div>`
1161
+ ).join('') + `</div>`;
1162
+ }
1163
+
1164
+ export function campaignHealth(invites) {
1165
+ const invited = invites.length;
1166
+ const completed = invites.filter(i => i.data.status === 'completed').length;
1167
+ return {invited, completed, responseRate: invited ? Math.round((completed / invited) * 100) : 0};
1168
+ }
1169
+
1170
+ export function toCsv(rows, columns) {
1171
+ const esc = (v) => {
1172
+ const s = String(v ?? '');
1173
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
1174
+ };
1175
+ const head = columns.join(',');
1176
+ const body = rows.map(r => columns.map(c => esc(r[c])).join(',')).join('\n');
1177
+ return `${head}\n${body}`;
1178
+ }
1179
+
1180
+ function escapeHtml(s) {
1181
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1182
+ }
1183
+ ```
1184
+
1185
+ - [ ] **Step 4: Run to verify pass**
1186
+
1187
+ Run: `node --test tests/surveys/stats.test.js`
1188
+ Expected: PASS (5 tests).
1189
+
1190
+ - [ ] **Step 5: Commit**
1191
+
1192
+ ```bash
1193
+ git add plugins/surveys/lib/stats.js tests/surveys/stats.test.js
1194
+ git commit -m "feat(surveys): stats aggregation, renderDistribution seam, CSV export"
1195
+ ```
1196
+
1197
+ ---
1198
+
1199
+ ## Phase 7 - Admin API (`plugin.js`)
1200
+
1201
+ `plugin.js` is mounted at `/api/plugins/surveys` with `options.auth.authenticate`. It wires the lib modules to real core services and exposes authed CRUD + actions.
1202
+
1203
+ ### Task 7.1: Wire core services + survey/audience CRUD
1204
+
1205
+ **Files:**
1206
+ - Create: `plugins/surveys/plugin.js`
1207
+ - Test: `tests/surveys/plugin-admin.test.js`
1208
+
1209
+ - [ ] **Step 1: Write the failing integration test** (real collections service, seeded; mirrors `tests/routes/api-tokens.test.js`)
1210
+
1211
+ ```js
1212
+ import {before, test} from 'node:test';
1213
+ import assert from 'node:assert/strict';
1214
+ import Fastify from 'fastify';
1215
+ import fastifyJwt from '@fastify/jwt';
1216
+ import surveysPlugin from '../../plugins/surveys/plugin.js';
1217
+ import {createCollection, getCollection} from '../../server/services/collections.js';
1218
+
1219
+ before(async () => {
1220
+ // Ensure the four plugin collections exist (setupPlugin does this in prod).
1221
+ for (const slug of ['surveys-contacts', 'surveys-groups', 'surveys-surveys', 'surveys-invites']) {
1222
+ if (!(await getCollection(slug).catch(() => null))) {
1223
+ await createCollection({slug, title: slug, plugin: 'surveys', fields: [{name: 'x', type: 'text'}]});
1224
+ }
1225
+ }
1226
+ });
1227
+
1228
+ async function buildApp() {
1229
+ const app = Fastify();
1230
+ app.register(fastifyJwt, {secret: 'test-secret-only-for-tests-32chars'});
1231
+ const authenticate = async (req) => { req.user = {id: 'admin1', role: 'super-admin'}; };
1232
+ await app.register(surveysPlugin, {prefix: '/api/plugins/surveys', settings: {}, auth: {authenticate}});
1233
+ return app;
1234
+ }
1235
+
1236
+ test('POST then GET /surveys round-trips a campaign', async () => {
1237
+ const app = await buildApp();
1238
+ const slug = 'sat-' + Date.now();
1239
+ const create = await app.inject({method: 'POST', url: '/api/plugins/surveys/surveys',
1240
+ payload: {slug, title: 'Sat', formSlug: 'sat-form', mode: 'anonymous'}});
1241
+ assert.equal(create.statusCode, 201);
1242
+ const list = await app.inject({method: 'GET', url: '/api/plugins/surveys/surveys'});
1243
+ assert.equal(list.statusCode, 200);
1244
+ assert.ok(JSON.parse(list.body).some(s => s.slug === slug));
1245
+ });
1246
+ ```
1247
+
1248
+ - [ ] **Step 2: Run to verify it fails**
1249
+
1250
+ Run: `node --test tests/surveys/plugin-admin.test.js`
1251
+ Expected: FAIL - cannot find module `plugin.js`.
1252
+
1253
+ - [ ] **Step 3: Implement** `plugins/surveys/plugin.js` - import core services and the lib modules, build the wired instances, register routes. Key contents:
1254
+
1255
+ ```js
1256
+ import defaultConfig from './config.js';
1257
+ import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
1258
+ import {createCampaigns} from './lib/campaigns.js';
1259
+ import {createAudience} from './lib/audience.js';
1260
+ import {createLedger} from './lib/ledger.js';
1261
+
1262
+ const col = {createEntry, deleteEntry, getEntry, listEntries, updateEntry};
1263
+
1264
+ export default async function surveysPlugin(fastify, options) {
1265
+ const {authenticate} = options.auth;
1266
+ const config = {...defaultConfig, ...(options.settings || {})};
1267
+ const campaigns = createCampaigns(col);
1268
+ const audience = createAudience(col);
1269
+ const ledger = createLedger(col);
1270
+
1271
+ const auth = {preHandler: [authenticate]};
1272
+
1273
+ // ---- Surveys (campaigns) ----
1274
+ fastify.get('/surveys', auth, async () => (await campaigns.all()).map(e => ({id: e.id, ...e.data})));
1275
+
1276
+ fastify.post('/surveys', auth, async (req, reply) => {
1277
+ try {
1278
+ const s = await campaigns.createSurvey(req.body ?? {});
1279
+ return reply.code(201).send({id: s.id, ...s.data});
1280
+ } catch (err) { return reply.code(400).send({error: err.message}); }
1281
+ });
1282
+
1283
+ fastify.get('/surveys/:slug', auth, async (req, reply) => {
1284
+ const s = await campaigns.getBySlug(req.params.slug);
1285
+ if (!s) return reply.code(404).send({error: 'Survey not found'});
1286
+ return {id: s.id, ...s.data};
1287
+ });
1288
+
1289
+ fastify.put('/surveys/:slug', auth, async (req, reply) => {
1290
+ const s = await campaigns.getBySlug(req.params.slug);
1291
+ if (!s) return reply.code(404).send({error: 'Survey not found'});
1292
+ const updated = await campaigns.updateSurvey(s.id, req.body ?? {});
1293
+ return {id: updated.id, ...updated.data};
1294
+ });
1295
+
1296
+ // ---- Audience: contacts ----
1297
+ fastify.get('/contacts', auth, async () => audience.listContacts());
1298
+ fastify.post('/contacts', auth, async (req, reply) => {
1299
+ const {name, email, groups} = req.body ?? {};
1300
+ if (!email) return reply.code(400).send({error: 'email is required'});
1301
+ const e = await createEntry('surveys-contacts', {name: name ?? '', email, groups: Array.isArray(groups) ? groups : []}, {source: 'admin'});
1302
+ return reply.code(201).send({id: e.id, ...e.data});
1303
+ });
1304
+ fastify.delete('/contacts/:id', auth, async (req, reply) => {
1305
+ await deleteEntry('surveys-contacts', req.params.id).catch(() => {});
1306
+ return {ok: true};
1307
+ });
1308
+
1309
+ // ---- Audience: groups ----
1310
+ fastify.get('/groups', auth, async () => {
1311
+ const {entries} = await listEntries('surveys-groups', {limit: 10000});
1312
+ return entries.map(e => ({id: e.id, name: e.data.name, description: e.data.description ?? ''}));
1313
+ });
1314
+ fastify.post('/groups', auth, async (req, reply) => {
1315
+ const {name, description} = req.body ?? {};
1316
+ if (!name) return reply.code(400).send({error: 'name is required'});
1317
+ const e = await createEntry('surveys-groups', {name, description: description ?? ''}, {source: 'admin'});
1318
+ return reply.code(201).send({id: e.id, ...e.data});
1319
+ });
1320
+
1321
+ // ---- Import from Contacts plugin ----
1322
+ fastify.post('/import-from-contacts', auth, async (req, reply) => {
1323
+ const {entries} = await listEntries('contacts-contacts', {limit: 100000}).catch(() => ({entries: []}));
1324
+ const source = entries.map(e => ({name: e.data.name, email: e.data.email, groups: e.data.groups}));
1325
+ const result = await audience.importFromContacts(source);
1326
+ return reply.send(result);
1327
+ });
1328
+
1329
+ // Sending + stats routes are added in later tasks.
1330
+ fastify.surveys = {campaigns, audience, ledger, col, config}; // expose for later tasks/tests
1331
+ }
1332
+ ```
1333
+
1334
+ - [ ] **Step 4: Run to verify pass**
1335
+
1336
+ Run: `node --test tests/surveys/plugin-admin.test.js`
1337
+ Expected: PASS.
1338
+
1339
+ - [ ] **Step 5: Commit**
1340
+
1341
+ ```bash
1342
+ git add plugins/surveys/plugin.js tests/surveys/plugin-admin.test.js
1343
+ git commit -m "feat(surveys): admin API - campaign + audience CRUD, contacts import"
1344
+ ```
1345
+
1346
+ ### Task 7.2: Send + reminder + stats routes
1347
+
1348
+ **Files:**
1349
+ - Modify: `plugins/surveys/plugin.js`
1350
+ - Test: `tests/surveys/plugin-send.test.js`
1351
+
1352
+ - [ ] **Step 1: Write the failing test** - issue invites for a survey's groups, assert ledger rows created and statuses set. (Use a survey whose `groupIds` matches a seeded contact's groups; stub SMTP by setting an invalid host so `sendBatch` records `ok:false` but the route still 200s and marks `bounced`.)
1353
+
1354
+ ```js
1355
+ import {before, test} from 'node:test';
1356
+ import assert from 'node:assert/strict';
1357
+ import Fastify from 'fastify';
1358
+ import fastifyJwt from '@fastify/jwt';
1359
+ import surveysPlugin from '../../plugins/surveys/plugin.js';
1360
+ import {createCollection, getCollection, createEntry, listEntries} from '../../server/services/collections.js';
1361
+
1362
+ before(async () => {
1363
+ for (const slug of ['surveys-contacts', 'surveys-groups', 'surveys-surveys', 'surveys-invites']) {
1364
+ if (!(await getCollection(slug).catch(() => null))) {
1365
+ await createCollection({slug, title: slug, plugin: 'surveys', fields: [{name: 'x', type: 'text'}]});
1366
+ }
1367
+ }
1368
+ });
1369
+
1370
+ async function buildApp() {
1371
+ const app = Fastify();
1372
+ app.register(fastifyJwt, {secret: 'test-secret-only-for-tests-32chars'});
1373
+ await app.register(surveysPlugin, {prefix: '/api/plugins/surveys', settings: {}, auth: {authenticate: async (req) => { req.user = {id: 'a', role: 'super-admin'}; }}});
1374
+ return app;
1375
+ }
1376
+
1377
+ test('POST /surveys/:slug/send issues invites for the audience and reports results', async () => {
1378
+ const app = await buildApp();
1379
+ const tag = 'send-' + Date.now();
1380
+ await createEntry('surveys-contacts', {name: 'A', email: 'a@example.test', groups: [tag]}, {source: 't'});
1381
+ const slug = 'camp-' + Date.now();
1382
+ await app.inject({method: 'POST', url: '/api/plugins/surveys/surveys', payload: {slug, title: 'C', formSlug: 'f', groupIds: [tag], status: 'open'}});
1383
+ const res = await app.inject({method: 'POST', url: `/api/plugins/surveys/surveys/${slug}/send`, payload: {audience: 'all'}});
1384
+ assert.equal(res.statusCode, 200);
1385
+ const body = JSON.parse(res.body);
1386
+ assert.equal(body.results.length, 1);
1387
+ const {entries} = await listEntries('surveys-invites', {limit: 100000});
1388
+ assert.ok(entries.some(e => ['sent', 'bounced'].includes(e.data.status)));
1389
+ });
1390
+ ```
1391
+
1392
+ - [ ] **Step 2: Run to verify it fails**
1393
+
1394
+ Run: `node --test tests/surveys/plugin-send.test.js`
1395
+ Expected: FAIL - 404 on `/send`.
1396
+
1397
+ - [ ] **Step 3: Implement** - add to `plugin.js` (before the `fastify.surveys =` line). Reuse `sending.js`, `email.js`, `getConfig`:
1398
+
1399
+ ```js
1400
+ import {createTransport, sendEmail} from '../../server/services/email.js';
1401
+ import {getConfig} from '../../server/config.js';
1402
+ import {sendBatch} from './lib/sending.js';
1403
+ import {createStatsApi} from './lib/stats-api.js'; // thin loader; see note
1404
+
1405
+ // inside surveysPlugin, after ledger is created:
1406
+
1407
+ async function recipientsFor(survey, mode) {
1408
+ const groupNames = Array.isArray(survey.data.groupIds) ? survey.data.groupIds : [];
1409
+ const contactIds = await audience.contactIdsForGroups(groupNames);
1410
+ const contacts = (await audience.listContacts()).filter(c => contactIds.includes(c.id));
1411
+ const issued = await ledger.issueInvites(survey.id, contactIds);
1412
+ const tokenByContact = new Map(issued.map(i => [i.contactId, i]));
1413
+ let chosen = contacts;
1414
+ if (mode === 'non-responders') {
1415
+ const outstanding = new Set((await ledger.outstanding(survey.id)).map(e => e.data.contactId));
1416
+ chosen = contacts.filter(c => outstanding.has(c.id));
1417
+ }
1418
+ // only contacts with a freshly-issued token can receive a personalised link
1419
+ return chosen.map(c => {
1420
+ const inv = tokenByContact.get(c.id);
1421
+ return inv ? {invite: {id: inv.id}, contact: c, token: inv.token} : null;
1422
+ }).filter(Boolean);
1423
+ }
1424
+
1425
+ fastify.post('/surveys/:slug/send', auth, async (req, reply) => {
1426
+ const survey = await campaigns.getBySlug(req.params.slug);
1427
+ if (!survey) return reply.code(404).send({error: 'Survey not found'});
1428
+ const {audience: mode = 'all'} = req.body ?? {};
1429
+ const recipients = await recipientsFor(survey, mode === 'non-responders' ? 'non-responders' : 'all');
1430
+ const smtp = getConfig('site').smtp || {};
1431
+ const transport = await createTransport(smtp);
1432
+ const results = await sendBatch(recipients, survey.data.inviteEmail || {}, {
1433
+ sendEmail, transport, from: smtp.fromAddress, fromName: smtp.fromName || config.fromName,
1434
+ baseUrl: getConfig('site').url || '', surveySlug: survey.data.slug
1435
+ });
1436
+ const nowIso = new Date().toISOString();
1437
+ for (const r of results) await ledger.markSent(r.inviteId, r.ok, nowIso);
1438
+ return reply.send({results});
1439
+ });
1440
+ ```
1441
+
1442
+ > **Note on `createStatsApi` import:** if you prefer not to add a `stats-api.js`, inline the stats route here instead. The stats route reads the survey's form (`readForm`), lists the form's submission collection (`listEntries(formSlug)`), runs `aggregateField` per field, and computes `campaignHealth(await ledger.invitesForSurvey(survey.id))`. Add `GET /surveys/:slug/stats` returning `{health, fields}` and `GET /surveys/:slug/export?format=csv|json`.
1443
+
1444
+ Add the stats route concretely:
1445
+
1446
+ ```js
1447
+ import {readForm} from '../../server/services/forms.js';
1448
+ import {aggregateField, campaignHealth, toCsv} from './lib/stats.js';
1449
+
1450
+ fastify.get('/surveys/:slug/stats', auth, async (req, reply) => {
1451
+ const survey = await campaigns.getBySlug(req.params.slug);
1452
+ if (!survey) return reply.code(404).send({error: 'Survey not found'});
1453
+ const form = await readForm(survey.data.formSlug).catch(() => null);
1454
+ const {entries} = await listEntries(survey.data.formSlug, {limit: 100000}).catch(() => ({entries: []}));
1455
+ const rows = entries.map(e => e.data);
1456
+ const fields = (form?.fields || [])
1457
+ .filter(f => !['page-break', 'spacer', 'heading', 'paragraph', 'html'].includes(f.type))
1458
+ .map(f => aggregateField(f, rows));
1459
+ const health = campaignHealth(await ledger.invitesForSurvey(survey.id));
1460
+ return reply.send({health, fields});
1461
+ });
1462
+
1463
+ fastify.get('/surveys/:slug/export', auth, async (req, reply) => {
1464
+ const survey = await campaigns.getBySlug(req.params.slug);
1465
+ if (!survey) return reply.code(404).send({error: 'Survey not found'});
1466
+ const form = await readForm(survey.data.formSlug).catch(() => null);
1467
+ const cols = (form?.fields || []).map(f => f.name).filter(Boolean);
1468
+ const {entries} = await listEntries(survey.data.formSlug, {limit: 100000}).catch(() => ({entries: []}));
1469
+ const rows = entries.map(e => e.data);
1470
+ if ((req.query.format || 'csv') === 'json') {
1471
+ return reply.header('Content-Disposition', `attachment; filename="${survey.data.slug}.json"`).send(rows);
1472
+ }
1473
+ return reply.header('Content-Type', 'text/csv')
1474
+ .header('Content-Disposition', `attachment; filename="${survey.data.slug}.csv"`)
1475
+ .send(toCsv(rows, cols));
1476
+ });
1477
+ ```
1478
+
1479
+ (Disregard the `createStatsApi` import line - implement the routes inline as above.)
1480
+
1481
+ - [ ] **Step 4: Run to verify pass**
1482
+
1483
+ Run: `node --test tests/surveys/plugin-send.test.js`
1484
+ Expected: PASS.
1485
+
1486
+ - [ ] **Step 5: Commit**
1487
+
1488
+ ```bash
1489
+ git add plugins/surveys/plugin.js tests/surveys/plugin-send.test.js
1490
+ git commit -m "feat(surveys): send/reminder batch + stats + export routes"
1491
+ ```
1492
+
1493
+ ---
1494
+
1495
+ ## Phase 8 - Public surface (`plugin.public.js`)
1496
+
1497
+ Root-mounted: hosted page, public submit, scheduler contract.
1498
+
1499
+ ### Task 8.1: Public submit endpoint
1500
+
1501
+ **Files:**
1502
+ - Create: `plugins/surveys/plugin.public.js`
1503
+ - Test: `tests/surveys/public-submit.test.js`
1504
+
1505
+ > **Carried-forward concern (from Task 3.1 code review):** `processSurveySubmission` does a check-then-act on the invite (read `status === 'completed'`, write the response, then `markCompleted`) with no lock. Under concurrent submits of the SAME token on the lock-free FileAdapter, both could pass the completed-check and create two response rows from one invite - defeating one-response-per-invite. Mitigate at THIS route layer: immediately before storing, re-read the invite via `ledger.findByCode` and abort with 409 if it is now `completed` (narrows but does not fully close the window). Add a test asserting two near-simultaneous submits of one token yield exactly one `completed` invite / one response. Full atomicity would need an adapter-level guard - out of scope; note it in `CLAUDE.md`.
1506
+
1507
+ - [ ] **Step 1: Write the failing test** - issue an invite, POST a valid answer to `/api/plugins/surveys/submit/:slug`, assert 200 + ledger completed; POST again → 409.
1508
+
1509
+ ```js
1510
+ import {before, test} from 'node:test';
1511
+ import assert from 'node:assert/strict';
1512
+ import Fastify from 'fastify';
1513
+ import publicPlugin from '../../plugins/surveys/plugin.public.js';
1514
+ import {createCollection, getCollection, createEntry, listEntries} from '../../server/services/collections.js';
1515
+ import {ensureFormsDir, writeForm} from '../../server/services/forms.js';
1516
+ import {createCampaigns} from '../../plugins/surveys/lib/campaigns.js';
1517
+ import {createLedger} from '../../plugins/surveys/lib/ledger.js';
1518
+
1519
+ const col = {createEntry, getEntry: (s, id) => import('../../server/services/collections.js').then(m => m.getEntry(s, id)), listEntries, updateEntry: (s, id, d) => import('../../server/services/collections.js').then(m => m.updateEntry(s, id, d))};
1520
+
1521
+ before(async () => {
1522
+ for (const slug of ['surveys-surveys', 'surveys-invites']) {
1523
+ if (!(await getCollection(slug).catch(() => null))) await createCollection({slug, title: slug, plugin: 'surveys', fields: [{name: 'x', type: 'text'}]});
1524
+ }
1525
+ await ensureFormsDir();
1526
+ await writeForm('ps-form', {slug: 'ps-form', title: 'PS', fields: [{name: 'q1', label: 'Q1', type: 'text', required: true}], actions: {}, settings: {}});
1527
+ if (!(await getCollection('ps-form').catch(() => null))) await createCollection({slug: 'ps-form', title: 'PS', fields: [{name: 'q1', type: 'text'}]});
1528
+ });
1529
+
1530
+ test('valid token submit completes the invite; second attempt 409', async () => {
1531
+ const slug = 'ps-' + Date.now();
1532
+ const camp = createCampaigns({createEntry, listEntries, getEntry: (s, id) => import('../../server/services/collections.js').then(m => m.getEntry(s, id)), updateEntry: (s, id, d) => import('../../server/services/collections.js').then(m => m.updateEntry(s, id, d))});
1533
+ const survey = await camp.createSurvey({slug, title: 'PS', formSlug: 'ps-form', status: 'open'});
1534
+ const ledger = createLedger({createEntry, listEntries, getEntry: (s, id) => import('../../server/services/collections.js').then(m => m.getEntry(s, id)), updateEntry: (s, id, d) => import('../../server/services/collections.js').then(m => m.updateEntry(s, id, d))});
1535
+ const [invite] = await ledger.issueInvites(survey.id, ['c1']);
1536
+
1537
+ const app = Fastify();
1538
+ await app.register(publicPlugin, {settings: {}, auth: {}, config: {}});
1539
+ const ok = await app.inject({method: 'POST', url: `/api/plugins/surveys/submit/${slug}`, payload: {token: invite.token, data: {q1: 'Great'}}});
1540
+ assert.equal(ok.statusCode, 200);
1541
+ const again = await app.inject({method: 'POST', url: `/api/plugins/surveys/submit/${slug}`, payload: {token: invite.token, data: {q1: 'Again'}}});
1542
+ assert.equal(again.statusCode, 409);
1543
+ });
1544
+ ```
1545
+
1546
+ > If the async-getEntry wrapper above proves awkward in practice, build a small synchronous adapter object `{createEntry, listEntries, getEntry, updateEntry}` by importing the four functions at the top of the test instead - the point of the test is the HTTP behaviour, not the wiring style.
1547
+
1548
+ - [ ] **Step 2: Run to verify it fails**
1549
+
1550
+ Run: `node --test tests/surveys/public-submit.test.js`
1551
+ Expected: FAIL - cannot find module `plugin.public.js`.
1552
+
1553
+ - [ ] **Step 3: Implement** `plugin.public.js`:
1554
+
1555
+ ```js
1556
+ import path from 'path';
1557
+ import {fileURLToPath} from 'url';
1558
+ import {createEntry, getEntry, listEntries, updateEntry, getCollection} from '../../server/services/collections.js';
1559
+ import {readForm} from '../../server/services/forms.js';
1560
+ import {createTransport, sendFormEmail} from '../../server/services/email.js';
1561
+ import {getConfig} from '../../server/config.js';
1562
+ import {createCampaigns} from './lib/campaigns.js';
1563
+ import {createLedger} from './lib/ledger.js';
1564
+ import {processSurveySubmission, SurveyError} from './lib/submit.js';
1565
+
1566
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
1567
+ const col = {createEntry, getEntry, listEntries, updateEntry};
1568
+
1569
+ export default async function surveysPublicPlugin(fastify, options) {
1570
+ const campaigns = createCampaigns(col);
1571
+ const ledger = createLedger(col);
1572
+
1573
+ async function sendCompletionEmail(form, data) {
1574
+ const ea = form.actions?.email;
1575
+ if (!ea?.enabled || !ea.recipients) return;
1576
+ const smtp = getConfig('site').smtp || {};
1577
+ const transport = await createTransport(smtp);
1578
+ await sendFormEmail(transport, {
1579
+ from: smtp.fromAddress, fromName: smtp.fromName, to: ea.recipients,
1580
+ subject: `${ea.subjectPrefix || `[${form.title}]`} New survey response`,
1581
+ formTitle: form.title, fields: form.fields, data
1582
+ });
1583
+ }
1584
+
1585
+ const deps = {
1586
+ col, ledger,
1587
+ getSurveyBySlug: (slug) => campaigns.getBySlug(slug),
1588
+ readForm,
1589
+ getCollection,
1590
+ sendCompletionEmail,
1591
+ now: () => new Date().toISOString()
1592
+ };
1593
+
1594
+ // Public submit
1595
+ fastify.post('/api/plugins/surveys/submit/:slug', async (req, reply) => {
1596
+ const {token, data} = req.body ?? {};
1597
+ try {
1598
+ const result = await processSurveySubmission(req.params.slug, token, data || {}, deps);
1599
+ return reply.send(result);
1600
+ } catch (err) {
1601
+ const code = err instanceof SurveyError ? err.statusCode : 500;
1602
+ return reply.code(code).send({error: err.message});
1603
+ }
1604
+ });
1605
+
1606
+ // Hosted page + scheduler contract are added in later tasks.
1607
+ fastify.surveysPublic = {campaigns, ledger, deps};
1608
+ }
1609
+ ```
1610
+
1611
+ - [ ] **Step 4: Run to verify pass**
1612
+
1613
+ Run: `node --test tests/surveys/public-submit.test.js`
1614
+ Expected: PASS.
1615
+
1616
+ - [ ] **Step 5: Commit**
1617
+
1618
+ ```bash
1619
+ git add plugins/surveys/plugin.public.js tests/surveys/public-submit.test.js
1620
+ git commit -m "feat(surveys): public tokenised submit endpoint"
1621
+ ```
1622
+
1623
+ ### Task 8.2: Scheduler contract (`/due-work`, `/run`)
1624
+
1625
+ **Files:**
1626
+ - Modify: `plugins/surveys/plugin.public.js`
1627
+ - Test: `tests/surveys/due-work.test.js`
1628
+
1629
+ - [ ] **Step 1: Write the failing test** - without a bearer token → 403; with the configured `schedulerToken` → 200 and a JSON array.
1630
+
1631
+ ```js
1632
+ import {test} from 'node:test';
1633
+ import assert from 'node:assert/strict';
1634
+ import Fastify from 'fastify';
1635
+ import publicPlugin from '../../plugins/surveys/plugin.public.js';
1636
+
1637
+ test('/due-work requires the scheduler token', async () => {
1638
+ const app = Fastify();
1639
+ await app.register(publicPlugin, {settings: {schedulerToken: 'dcms_secret'}, auth: {}, config: {}});
1640
+ const no = await app.inject({method: 'GET', url: '/api/plugins/surveys/due-work'});
1641
+ assert.equal(no.statusCode, 403);
1642
+ const yes = await app.inject({method: 'GET', url: '/api/plugins/surveys/due-work', headers: {authorization: 'Bearer dcms_secret'}});
1643
+ assert.equal(yes.statusCode, 200);
1644
+ assert.ok(Array.isArray(JSON.parse(yes.body)));
1645
+ });
1646
+ ```
1647
+
1648
+ - [ ] **Step 2: Run to verify it fails**
1649
+
1650
+ Run: `node --test tests/surveys/due-work.test.js`
1651
+ Expected: FAIL - 404.
1652
+
1653
+ - [ ] **Step 3: Implement** - add to `plugin.public.js`. Use `options.settings.schedulerToken`:
1654
+
1655
+ ```js
1656
+ const schedulerToken = options.settings?.schedulerToken || '';
1657
+
1658
+ function requireScheduler(req, reply) {
1659
+ const hdr = req.headers.authorization || '';
1660
+ const tok = hdr.startsWith('Bearer ') ? hdr.slice(7) : '';
1661
+ if (!schedulerToken || tok !== schedulerToken) { reply.code(403).send({error: 'Forbidden'}); return false; }
1662
+ return true;
1663
+ }
1664
+
1665
+ // Generic due-work: open campaigns with invites past their reminder threshold.
1666
+ fastify.get('/api/plugins/surveys/due-work', async (req, reply) => {
1667
+ if (!requireScheduler(req, reply)) return;
1668
+ const now = new Date();
1669
+ const surveys = (await campaigns.all()).filter(s => s.data.status === 'open');
1670
+ const out = [];
1671
+ for (const s of surveys) {
1672
+ const due = await ledger.dueForReminder(s.id, Number(s.data.reminderAfterDays) || 7, now);
1673
+ if (due.length) out.push({plugin: 'surveys', campaignSlug: s.data.slug, dueCount: due.length});
1674
+ }
1675
+ return reply.send(out);
1676
+ });
1677
+
1678
+ // Generic run: execute a batch for a campaign (reminder or full).
1679
+ fastify.post('/api/plugins/surveys/run', async (req, reply) => {
1680
+ if (!requireScheduler(req, reply)) return;
1681
+ const {campaignSlug, audience: mode = 'non-responders', reminder = true} = req.body ?? {};
1682
+ const survey = await campaigns.getBySlug(campaignSlug);
1683
+ if (!survey) return reply.code(404).send({error: 'Survey not found'});
1684
+ // Reuse the same recipient + send path as the admin route by importing the
1685
+ // shared helper. For reminders, links omit ?t= (recipients use their code).
1686
+ const result = await runScheduledBatch(survey, {mode, reminder});
1687
+ return reply.send(result);
1688
+ });
1689
+ ```
1690
+
1691
+ > **`runScheduledBatch`:** factor the admin route's recipient-resolution + `sendBatch` + `markSent` loop from Task 7.2 into an exported helper in `lib/sending.js` (e.g. `export async function runSend({survey, mode, reminder, audience, ledger, sendEmail, smtp, baseUrl, fromName})`) and call it from BOTH the admin `/send` route and this `/run` route. This removes duplication (DRY) - refactor Task 7.2's inline logic into that helper when you reach this step, re-running `tests/surveys/plugin-send.test.js` to confirm no regression.
1692
+
1693
+ - [ ] **Step 4: Run to verify pass**
1694
+
1695
+ Run: `node --test tests/surveys/due-work.test.js && node --test tests/surveys/plugin-send.test.js`
1696
+ Expected: PASS (both).
1697
+
1698
+ - [ ] **Step 5: Commit**
1699
+
1700
+ ```bash
1701
+ git add plugins/surveys/plugin.public.js plugins/surveys/lib/sending.js tests/surveys/due-work.test.js
1702
+ git commit -m "feat(surveys): scheduler-ready due-work + run contract"
1703
+ ```
1704
+
1705
+ ### Task 8.3: Hosted page (`GET /survey/:slug`) + client + shortcode
1706
+
1707
+ **Files:**
1708
+ - Modify: `plugins/surveys/plugin.public.js`
1709
+ - Create: `plugins/surveys/templates/survey-page.html`
1710
+ - Create: `plugins/surveys/public/survey.mjs`
1711
+ - Create: `plugins/surveys/public/survey.css`
1712
+
1713
+ > **Browser-verified, not unit-tested** (project memory: never claim UI verified from curl/inject alone). Tests here are manual.
1714
+
1715
+ - [ ] **Step 1: Write `templates/survey-page.html`** - a public SSR shell with `{{title}}`, a `#survey-mount` div, a `#survey-code` manual-entry box (shown when no token), and script/style tags loading `/plugins/surveys/public/survey.mjs` (as `type="module"`) and `/plugins/surveys/public/survey.css`. Mirror `plugins/blog/templates/*.html` head/asset structure and load the Domma dist (`/dist/domma/...`) the same way the public page shell does.
1716
+
1717
+ - [ ] **Step 2: Implement `GET /survey/:slug`** in `plugin.public.js`:
1718
+
1719
+ ```js
1720
+ import fs from 'fs/promises';
1721
+ fastify.get('/survey/:slug', async (req, reply) => {
1722
+ const survey = await campaigns.getBySlug(req.params.slug);
1723
+ if (!survey || survey.data.status !== 'open') {
1724
+ return reply.code(survey ? 410 : 404).type('text/html').send('<h1>This survey is not available.</h1>');
1725
+ }
1726
+ let html = await fs.readFile(path.join(__dirname, 'templates', 'survey-page.html'), 'utf8');
1727
+ html = html
1728
+ .replaceAll('{{title}}', escapeHtml(survey.data.title))
1729
+ .replaceAll('{{slug}}', escapeHtml(survey.data.slug))
1730
+ .replaceAll('{{formSlug}}', escapeHtml(survey.data.formSlug));
1731
+ return reply.type('text/html').send(html);
1732
+ });
1733
+ ```
1734
+
1735
+ (Add a local `escapeHtml` helper as in `plugin.js`.)
1736
+
1737
+ - [ ] **Step 3: Write `public/survey.mjs`** - the client:
1738
+ 1. read `?t=` from `location.search` (or show `#survey-code` box; on submit normalise via the same alphabet),
1739
+ 2. `fetch('/api/forms/' + formSlug + '/public').then(r => r.json())` to get the form def (do NOT use `H.get` for non-JSON - this IS JSON so `H.get` is fine, but plain `fetch().then(r=>r.json())` avoids the gotcha),
1740
+ 3. build a Domma blueprint from the form def fields and render with `Domma.forms.render(mountEl, blueprint, {}, opts)`,
1741
+ 4. on submit, `H.post('/api/plugins/surveys/submit/' + slug, {token, data})`,
1742
+ 5. on success show the thank-you state via `E.toast` + replacing the mount content; on 409/410/403 show the matching message.
1743
+
1744
+ - [ ] **Step 4: Write `public/survey.css`** - styles for `.survey-dist*` bars (shared with the admin results view) and the code-entry box.
1745
+
1746
+ - [ ] **Step 5: Register the `[survey]` shortcode** - in `plugin.js` (or a shared init), call the injected `hooks.registerShortcode('survey', handler)` where the handler returns the same `#survey-mount` markup + a `data-survey-slug` attribute, and ensure the page loads `survey.mjs`. (Shortcode is registered via the `hooks` passed to `plugin.js` options - confirm the hooks object is available; the loader passes `hooks` in `options` per `server/services/plugins.js:214`.)
1747
+
1748
+ - [ ] **Step 6: Manual browser verification** (per memory - required before claiming it works):
1749
+ - Start nothing (server always running on 4096). Clear cache via `POST /api/cache/clear` with a JWT minted from `JWT_SECRET` (see `project_dev_server_no_hot_reload`).
1750
+ - Create a test form + survey + contact via the admin UI; send an invite to a real inbox you control (or read the token from the `surveys-invites` entry in dev).
1751
+ - Load `http://192.168.1.4:4096/survey/<slug>?t=<code>` in a browser, complete it, confirm thank-you state and that the invite row flipped to `completed`.
1752
+ - Reload with the same token → "already completed" message.
1753
+ - Embed `[survey slug="<slug>"]` in a page → confirm it renders and submits.
1754
+
1755
+ - [ ] **Step 7: Commit**
1756
+
1757
+ ```bash
1758
+ git add plugins/surveys/plugin.public.js plugins/surveys/templates plugins/surveys/public
1759
+ git commit -m "feat(surveys): hosted survey page, client renderer, and [survey] shortcode"
1760
+ ```
1761
+
1762
+ ---
1763
+
1764
+ ## Phase 9 - Admin UI (Domma views)
1765
+
1766
+ > **Browser-verified, not unit-tested.** Each view mirrors `plugins/contacts/admin/views/contacts.js` + its `.html` template exactly: `export const <name>View = { templateUrl, async onMount($container) {...} }`. Use `$container.find('#id').get(0)`, `T.create`, `F.create`, `E.*`, `H.*`, `I.scan()`. No `?v=N` on imports.
1767
+
1768
+ ### Task 9.1: Surveys list view
1769
+
1770
+ **Files:**
1771
+ - Create: `plugins/surveys/admin/templates/surveys.html`
1772
+ - Create: `plugins/surveys/admin/views/surveys.js`
1773
+
1774
+ - [ ] **Step 1:** Write `surveys.html` - a header with a "New Survey" button (`#new-survey`) and a `#surveys-table` mount.
1775
+ - [ ] **Step 2:** Write `surveys.js` - `onMount` does `H.get('/api/plugins/surveys/surveys')`, then `T.create($container.find('#surveys-table').get(0), {data, columns})`. Columns: title, status (badge), response-rate (from a per-row `H.get` stats call or a lightweight count), and an actions column whose render returns an HTML string with `onclick` handlers (NOT addEventListener - double-bind gotcha) navigating to `#/plugins/surveys/edit/:slug` and `#/plugins/surveys/results/:slug`. Wire `#new-survey` to `#/plugins/surveys/new`.
1776
+ - [ ] **Step 3: Browser-verify** the list renders, badges show, buttons navigate.
1777
+ - [ ] **Step 4: Commit**
1778
+
1779
+ ```bash
1780
+ git add plugins/surveys/admin/templates/surveys.html plugins/surveys/admin/views/surveys.js
1781
+ git commit -m "feat(surveys): admin surveys list view"
1782
+ ```
1783
+
1784
+ ### Task 9.2: Survey editor view
1785
+
1786
+ **Files:**
1787
+ - Create: `plugins/surveys/admin/templates/survey-editor.html`
1788
+ - Create: `plugins/surveys/admin/views/survey-editor.js`
1789
+
1790
+ - [ ] **Step 1:** Template with a `#survey-form` mount + Save button.
1791
+ - [ ] **Step 2:** `onMount` builds the editor with `F.create('#survey-form', {blueprint})` where blueprint covers: title, slug (disabled on edit), description, formSlug (a select populated from `H.get('/api/forms')`), groupIds (multi-select from `H.get('/api/plugins/surveys/groups')`), mode (`identified|anonymous` radio), status (`draft|open|closed`), opensAt/closesAt (date), reminderAfterDays (number), inviteEmail.subject + inviteEmail.body (textareas with `{{name}}`/`{{link}}` hint). On submit, POST (new) or PUT (edit) to `/api/plugins/surveys/surveys[/:slug]`; `E.toast` on success; navigate back to the list. On the `:slug` route, pre-load via `H.get('/api/plugins/surveys/surveys/:slug')`.
1792
+ - [ ] **Step 3: Browser-verify** create + edit round-trip; confirm anonymous/identified persists.
1793
+ - [ ] **Step 4: Commit**
1794
+
1795
+ ```bash
1796
+ git add plugins/surveys/admin/templates/survey-editor.html plugins/surveys/admin/views/survey-editor.js
1797
+ git commit -m "feat(surveys): admin survey editor view"
1798
+ ```
1799
+
1800
+ ### Task 9.3: Audience view
1801
+
1802
+ **Files:**
1803
+ - Create: `plugins/surveys/admin/templates/audience.html`
1804
+ - Create: `plugins/surveys/admin/views/audience.js`
1805
+
1806
+ - [ ] **Step 1:** Template with a contacts `T.create` mount, an "Add contact" (`F.create` in an `E.modal`), a groups panel, and an "Import from Contacts" button (`#import-contacts`).
1807
+ - [ ] **Step 2:** `onMount` loads `/contacts` + `/groups`; renders the table; wires add/delete (`E.confirm` before delete); `#import-contacts` calls `H.post('/api/plugins/surveys/import-from-contacts')` then `E.toast('Imported N contacts')` and refreshes. Modal buttons via light-DOM slot projection (gotcha #3).
1808
+ - [ ] **Step 3: Browser-verify** add/delete/import; confirm imported contacts appear.
1809
+ - [ ] **Step 4: Commit**
1810
+
1811
+ ```bash
1812
+ git add plugins/surveys/admin/templates/audience.html plugins/surveys/admin/views/audience.js
1813
+ git commit -m "feat(surveys): admin audience view with contacts import"
1814
+ ```
1815
+
1816
+ ### Task 9.4: Results view
1817
+
1818
+ **Files:**
1819
+ - Create: `plugins/surveys/admin/templates/results.html`
1820
+ - Create: `plugins/surveys/admin/views/results.js`
1821
+
1822
+ - [ ] **Step 1:** Template with health-widget row, a per-question section mount, an outstanding-list mount, and CSV/JSON export buttons.
1823
+ - [ ] **Step 2:** `onMount` calls `H.get('/api/plugins/surveys/surveys/:slug/stats')`; renders health (invited/completed/rate); for each `fields[]` entry: choice/rating → inject `renderDistribution(field.items)` HTML (import the same function from `../../lib/stats.js`? - NOTE: admin views are browser ESM; import via a small shared client copy or duplicate the ~8-line `renderDistribution` in the view to avoid a server import. Prefer a shared `plugins/surveys/public/dist-shared.mjs` exporting `renderDistribution`, imported by BOTH `survey.mjs` and this view). Text fields → list responses. Export buttons link to `/api/plugins/surveys/surveys/:slug/export?format=csv|json` (open in new tab with the auth header - use `H.get` + blob download, or a tokenised link).
1824
+ - [ ] **Step 3: Browser-verify** with a survey that has responses: bars render, rate correct, export downloads.
1825
+ - [ ] **Step 4: Commit**
1826
+
1827
+ ```bash
1828
+ git add plugins/surveys/admin/templates/results.html plugins/surveys/admin/views/results.js plugins/surveys/public/dist-shared.mjs
1829
+ git commit -m "feat(surveys): admin results view - health, distributions, export"
1830
+ ```
1831
+
1832
+ > **Refactor note:** when you create `public/dist-shared.mjs` with `renderDistribution`, update `lib/stats.js` to keep the server copy (used for any server-side rendering/tests) and the client copy in sync - or have `lib/stats.js` re-export from a shared location. Keep ONE canonical implementation; the test in `tests/surveys/stats.test.js` must still pass against whichever module it imports.
1833
+
1834
+ ---
1835
+
1836
+ ## Phase 10 - Permissions & wiring
1837
+
1838
+ ### Task 10.1: Register the `surveys` permission resource
1839
+
1840
+ **Files:**
1841
+ - Modify: `plugins/surveys/plugin.js`
1842
+
1843
+ - [ ] **Step 1:** In `surveysPlugin`, register the permission family on load using the injected hooks/registry. The loader exposes `registerSidebarItem` etc. via `options.hooks`; permission registration uses `permissionRegistry.registerPluginResource`. Add at the top of `surveysPlugin`:
1844
+
1845
+ ```js
1846
+ import {registerPluginResource} from '../../server/services/permissionRegistry.js';
1847
+
1848
+ // inside surveysPlugin, once:
1849
+ registerPluginResource({
1850
+ key: 'surveys',
1851
+ label: 'Surveys',
1852
+ actions: ['read', 'create', 'update', 'delete']
1853
+ }, 'surveys');
1854
+ ```
1855
+
1856
+ - [ ] **Step 2:** Add `requirePermission`-style guards if the loader provides them; otherwise keep `authenticate` and rely on the sidebar `permission: "surveys"` filter (matches the Projects/Menus pattern - admins grant `surveys.read` in the role editor). Document in `CLAUDE.md` that custom roles need a manual grant (not back-filled).
1857
+
1858
+ - [ ] **Step 3:** Verify the whole suite passes: `npm test`. Expected: all `tests/surveys/*.test.js` pass alongside the existing suite.
1859
+
1860
+ - [ ] **Step 4: Commit**
1861
+
1862
+ ```bash
1863
+ git add plugins/surveys/plugin.js plugins/surveys/CLAUDE.md
1864
+ git commit -m "feat(surveys): register surveys permission family"
1865
+ ```
1866
+
1867
+ ### Task 10.2: Enable + smoke-test end to end
1868
+
1869
+ - [ ] **Step 1:** Add `surveys` to `config/plugins.json` (enabled: true) - confirm the format from an existing entry.
1870
+ - [ ] **Step 2:** Restart is NOT automatic (no hot reload). The user runs the server; ask them to restart it (or restart via their normal process) so `setupPlugin` seeds the four collections.
1871
+ - [ ] **Step 3:** Confirm startup log `[plugins] Loaded N plugins:` includes `surveys`, and that `content/collections/surveys-*` (file mode) or the Mongo `cms_surveys-*` collections exist.
1872
+ - [ ] **Step 4:** Full manual pass: create form → create survey (identified) → add contacts to a group → send → complete via hosted link → check Results show 100% + the answer; repeat with an anonymous survey and confirm the export contains no contact identity.
1873
+ - [ ] **Step 5: Commit** any config change:
1874
+
1875
+ ```bash
1876
+ git add config/plugins.json
1877
+ git commit -m "chore(surveys): enable plugin"
1878
+ ```
1879
+
1880
+ ---
1881
+
1882
+ ## Self-Review (completed during planning)
1883
+
1884
+ **Spec coverage:**
1885
+ - Audience model + import → Phase 0.2, 4.1, 7.1 (`/import-from-contacts`). ✓
1886
+ - Mongo-with-file-fallback storage → schemas declare adapter (0.2); helpers are adapter-agnostic. ✓
1887
+ - Per-survey identified/anonymous → submit.js (3.1) + campaigns (4.2) + editor (9.2). ✓
1888
+ - Hosted page + `[survey]` shortcode → 8.3. ✓
1889
+ - Token (readable, hashed) → 1.1; ledger → 2.x. ✓
1890
+ - Editor-triggered send + reminders → 7.2; due-work/run contract → 8.2. ✓
1891
+ - Layered stats + CSV/JSON export → 6.1 + 7.2 + 9.4. ✓
1892
+ - `renderDistribution` seam → 6.1; upstream stub referenced. ✓
1893
+ - Permissions family → 10.1. ✓
1894
+ - Error handling (invalid/used/closed/validation/bounce/anonymity) → 3.1 tests + 7.2. ✓
1895
+ - Out-of-scope (project scoping, open-tracking) → intentionally absent. ✓
1896
+
1897
+ **Placeholder scan:** The `createStatsApi` import in Task 7.2 is explicitly retracted in the same task with concrete inline route code; the async-getEntry wrapper in Task 8.1 has a concrete fallback note. No `TBD`/`TODO`. `renderDistribution` duplication is resolved by the Phase 9.4 shared-module note. ✓
1898
+
1899
+ **Type/name consistency:** `processSurveySubmission(surveySlug, code, body, deps)`, `createLedger(col)` → `{issueInvites, findByCode, markSent, markCompleted, outstanding, dueForReminder, invitesForSurvey}`, `aggregateField/renderDistribution/campaignHealth/toCsv`, collection slugs `surveys-{contacts,groups,surveys,invites}` - used consistently across tasks and tests. ✓
1900
+
1901
+ **Open item flagged for implementer:** the scheduler auth header is a shared-secret Bearer (`schedulerToken`) for v1; if domma-cms-manager uses a different scheme, adjust `requireScheduler` only. ✓
1902
+ ```