domma-cms 0.30.0 → 0.32.1

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 (43) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +4 -4
  3. package/admin/js/lib/themes.js +1 -1
  4. package/admin/js/templates/effects.html +752 -752
  5. package/admin/js/templates/forms.html +17 -17
  6. package/admin/js/templates/my-profile.html +17 -17
  7. package/admin/js/templates/role-editor.html +70 -70
  8. package/admin/js/templates/roles.html +10 -10
  9. package/admin/js/views/settings.js +1 -1
  10. package/bin/lib/config-merge.js +44 -44
  11. package/config/site.json +1 -0
  12. package/package.json +2 -2
  13. package/plugins/surveys/admin/templates/audience.html +47 -0
  14. package/plugins/surveys/admin/templates/results.html +58 -0
  15. package/plugins/surveys/admin/templates/survey-editor.html +141 -0
  16. package/plugins/surveys/admin/templates/surveys.html +25 -0
  17. package/plugins/surveys/admin/views/audience.js +301 -0
  18. package/plugins/surveys/admin/views/results.js +172 -0
  19. package/plugins/surveys/admin/views/survey-editor.js +211 -0
  20. package/plugins/surveys/admin/views/surveys.js +161 -0
  21. package/plugins/surveys/collections/survey-contacts/schema.json +13 -0
  22. package/plugins/surveys/collections/survey-groups/schema.json +11 -0
  23. package/plugins/surveys/collections/survey-invites/schema.json +16 -0
  24. package/plugins/surveys/collections/surveys/schema.json +23 -0
  25. package/plugins/surveys/config.js +8 -0
  26. package/plugins/surveys/plugin.js +169 -0
  27. package/plugins/surveys/plugin.json +65 -0
  28. package/plugins/surveys/public/dist-shared.mjs +1 -0
  29. package/plugins/surveys/public/survey.css +1 -0
  30. package/plugins/surveys/public/survey.mjs +1 -0
  31. package/plugins/surveys/templates/survey-page.html +63 -0
  32. package/server/middleware/auth.js +253 -253
  33. package/server/routes/api/auth.js +309 -309
  34. package/server/routes/api/navigation.js +42 -42
  35. package/server/routes/api/settings.js +141 -141
  36. package/server/routes/docs-public.js +43 -0
  37. package/server/routes/public.js +2 -2
  38. package/server/server.js +13 -1
  39. package/{scripts/migrate-docs.js → server/services/docs.js} +204 -126
  40. package/server/services/email.js +167 -167
  41. package/server/services/userProfiles.js +199 -199
  42. package/server/services/users.js +302 -302
  43. package/config/connections.json.bak +0 -9
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Surveys plugin — campaign create/edit view.
3
+ *
4
+ * The form is hand-built in the template as native inputs laid out in an
5
+ * explicit CSS grid (guaranteed multi-column), so this view only populates the
6
+ * dynamic options (forms, groups), seeds values on edit, collects the payload,
7
+ * and POST/PUTs to the admin API. On an open survey it also exposes the
8
+ * send / reminder actions.
9
+ *
10
+ * @module surveys/admin/views/survey-editor
11
+ */
12
+
13
+ const BASE = '/api/plugins/surveys';
14
+
15
+ /** Authenticated JSON fetch helper. Mirrors plugins/contacts/admin/views/contacts.js. */
16
+ async function api(url, method = 'GET', body) {
17
+ const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
18
+ if (body !== undefined) {
19
+ opts.headers['Content-Type'] = 'application/json';
20
+ opts.body = JSON.stringify(body);
21
+ }
22
+ const res = await fetch(url, opts);
23
+ if (!res.ok) {
24
+ const err = await res.json().catch(() => ({error: res.statusText}));
25
+ throw new Error(err.error || res.statusText);
26
+ }
27
+ const text = await res.text();
28
+ return text ? JSON.parse(text) : {};
29
+ }
30
+
31
+ export const surveyEditorView = {
32
+ templateUrl: '/plugins/surveys/admin/templates/survey-editor.html',
33
+
34
+ async onMount($container) {
35
+ const root = $container.find('.survey-editor-plugin').get(0) || $container.get(0);
36
+ if (!root) return;
37
+
38
+ const hashMatch = location.hash.match(/^#\/plugins\/surveys\/edit\/(.+)$/);
39
+ const editSlug = hashMatch ? decodeURIComponent(hashMatch[1]) : null;
40
+ const isEdit = !!editSlug;
41
+
42
+ const titleEl = root.querySelector('#editor-title');
43
+ if (titleEl) titleEl.textContent = isEdit ? 'Edit Survey' : 'New Survey';
44
+
45
+ // ---- Helpers to read/write the native inputs by field name ----------
46
+ const field = (name) => root.querySelector(`[name="${name}"]`);
47
+
48
+ function setValue(name, value) {
49
+ const el = field(name);
50
+ if (!el) return;
51
+ if (el.multiple) {
52
+ const set = new Set(Array.isArray(value) ? value : []);
53
+ Array.from(el.options).forEach((o) => { o.selected = set.has(o.value); });
54
+ } else {
55
+ el.value = value ?? '';
56
+ }
57
+ }
58
+
59
+ function readField(name) {
60
+ const el = field(name);
61
+ if (!el) return undefined;
62
+ if (el.multiple) return Array.from(el.selectedOptions).map((o) => o.value);
63
+ if (el.type === 'number') return el.value === '' ? '' : Number(el.value);
64
+ return el.value;
65
+ }
66
+
67
+ // ---- Load reference data + (on edit) the survey ---------------------
68
+ let survey = null;
69
+ let forms = [];
70
+ let groups = [];
71
+ try { forms = await api('/api/forms'); } catch { forms = []; }
72
+ try { groups = await api(`${BASE}/groups`); } catch { groups = []; }
73
+ if (isEdit) {
74
+ try {
75
+ survey = await api(`${BASE}/surveys/${encodeURIComponent(editSlug)}`);
76
+ } catch (err) {
77
+ E.toast(err?.message ?? 'Failed to load survey.', {type: 'error'});
78
+ location.hash = '#/plugins/surveys';
79
+ return;
80
+ }
81
+ }
82
+
83
+ // ---- Populate dynamic <select> options ------------------------------
84
+ const formSelect = field('formSlug');
85
+ if (formSelect) {
86
+ forms.forEach((f) => {
87
+ const opt = document.createElement('option');
88
+ opt.value = f.slug;
89
+ opt.textContent = f.title || f.slug;
90
+ formSelect.appendChild(opt);
91
+ });
92
+ }
93
+ // Audience uses group NAMES (sending.js reads groupIds as names).
94
+ const groupSelect = field('groupIds');
95
+ if (groupSelect) {
96
+ groups.forEach((g) => {
97
+ const opt = document.createElement('option');
98
+ opt.value = g.name;
99
+ opt.textContent = g.name;
100
+ groupSelect.appendChild(opt);
101
+ });
102
+ }
103
+
104
+ // ---- Seed values ----------------------------------------------------
105
+ const invite = survey?.inviteEmail ?? {};
106
+ setValue('title', survey?.title ?? '');
107
+ setValue('slug', survey?.slug ?? '');
108
+ setValue('description', survey?.description ?? '');
109
+ setValue('formSlug', survey?.formSlug ?? '');
110
+ setValue('mode', survey?.mode ?? 'identified');
111
+ setValue('status', survey?.status ?? 'draft');
112
+ setValue('groupIds', Array.isArray(survey?.groupIds) ? survey.groupIds : []);
113
+ setValue('opensAt', survey?.opensAt ?? '');
114
+ setValue('closesAt', survey?.closesAt ?? '');
115
+ setValue('reminderAfterDays', survey?.reminderAfterDays ?? 7);
116
+ setValue('intro', survey?.intro ?? '');
117
+ setValue('footer', survey?.footer ?? '');
118
+ setValue('thankYou', survey?.thankYou ?? '');
119
+ setValue('inviteSubject', invite.subject ?? '');
120
+ setValue('inviteBody', invite.body ?? '');
121
+
122
+ // Slug is immutable after creation.
123
+ if (isEdit) {
124
+ const slugEl = field('slug');
125
+ if (slugEl) slugEl.disabled = true;
126
+ const hint = root.querySelector('#slug-hint');
127
+ if (hint) hint.textContent = 'Slug cannot be changed after creation.';
128
+ }
129
+
130
+ // ---- Collect + save -------------------------------------------------
131
+ function collect() {
132
+ return {
133
+ title: (readField('title') ?? '').trim(),
134
+ slug: (readField('slug') ?? '').trim(),
135
+ description: (readField('description') ?? '').trim(),
136
+ formSlug: readField('formSlug') ?? '',
137
+ mode: readField('mode') ?? 'identified',
138
+ status: readField('status') ?? 'draft',
139
+ groupIds: readField('groupIds') ?? [],
140
+ opensAt: readField('opensAt') ?? '',
141
+ closesAt: readField('closesAt') ?? '',
142
+ reminderAfterDays: Number(readField('reminderAfterDays')) || 0,
143
+ intro: (readField('intro') ?? '').trim(),
144
+ footer: (readField('footer') ?? '').trim(),
145
+ thankYou: (readField('thankYou') ?? '').trim(),
146
+ inviteEmail: {
147
+ subject: (readField('inviteSubject') ?? '').trim(),
148
+ body: (readField('inviteBody') ?? '').trim()
149
+ }
150
+ };
151
+ }
152
+
153
+ async function save() {
154
+ const body = collect();
155
+ if (!body.title) { E.toast('Title is required.', {type: 'error'}); return; }
156
+ if (!isEdit && !body.slug) { E.toast('Slug is required.', {type: 'error'}); return; }
157
+ try {
158
+ if (isEdit) {
159
+ const {slug, ...patch} = body; // slug immutable
160
+ await api(`${BASE}/surveys/${encodeURIComponent(editSlug)}`, 'PUT', patch);
161
+ E.toast('Survey updated.', {type: 'success'});
162
+ } else {
163
+ await api(`${BASE}/surveys`, 'POST', body);
164
+ E.toast('Survey created.', {type: 'success'});
165
+ }
166
+ location.hash = '#/plugins/surveys';
167
+ } catch (err) {
168
+ E.toast(err?.message ?? 'Failed to save survey.', {type: 'error'});
169
+ }
170
+ }
171
+
172
+ const saveBtn = root.querySelector('#save-btn');
173
+ if (saveBtn) {
174
+ saveBtn.textContent = isEdit ? 'Update Survey' : 'Create Survey';
175
+ saveBtn.addEventListener('click', save);
176
+ }
177
+ const goBack = () => { location.hash = '#/plugins/surveys'; };
178
+ root.querySelector('#cancel-btn')?.addEventListener('click', goBack);
179
+ root.querySelector('#back-btn')?.addEventListener('click', goBack);
180
+
181
+ // ---- Send / reminder (edit + open only) -----------------------------
182
+ const sendPanel = root.querySelector('#send-panel');
183
+ const sendHint = root.querySelector('#send-hint');
184
+ if (sendPanel && isEdit) {
185
+ sendPanel.style.display = '';
186
+ if (sendHint) {
187
+ sendHint.textContent = survey?.status === 'open'
188
+ ? ''
189
+ : 'Survey must be Open before invitations are meaningful.';
190
+ }
191
+ }
192
+
193
+ async function send(audience, label) {
194
+ const ok = await E.confirm(`${label}?`);
195
+ if (!ok) return;
196
+ try {
197
+ const res = await api(`${BASE}/surveys/${encodeURIComponent(editSlug)}/send`, 'POST', {audience});
198
+ const results = Array.isArray(res?.results) ? res.results : [];
199
+ const sent = results.filter((r) => r.ok).length;
200
+ E.toast(`${sent} of ${results.length} invitation(s) sent.`, {type: 'success'});
201
+ } catch (err) {
202
+ E.toast(err?.message ?? 'Send failed.', {type: 'error'});
203
+ }
204
+ }
205
+
206
+ root.querySelector('#send-all-btn')?.addEventListener('click', () => send('all', 'Send invitations to the whole audience'));
207
+ root.querySelector('#send-reminder-btn')?.addEventListener('click', () => send('non-responders', 'Send a reminder to non-responders'));
208
+
209
+ if (typeof Domma !== 'undefined' && Domma.icons) Domma.icons.scan();
210
+ }
211
+ };
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Surveys plugin — campaign list view.
3
+ *
4
+ * Lists survey campaigns in a Domma table with status/mode badges and
5
+ * row actions that navigate to the editor and results views.
6
+ *
7
+ * @module surveys/admin/views/surveys
8
+ */
9
+
10
+ const BASE = '/api/plugins/surveys';
11
+
12
+ /**
13
+ * Authenticated JSON fetch helper. Mirrors plugins/contacts/admin/views/contacts.js.
14
+ *
15
+ * @param {string} url
16
+ * @param {string} [method]
17
+ * @param {object} [body]
18
+ * @returns {Promise<*>}
19
+ */
20
+ async function api(url, method = 'GET', body) {
21
+ const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
22
+ if (body !== undefined) {
23
+ opts.headers['Content-Type'] = 'application/json';
24
+ opts.body = JSON.stringify(body);
25
+ }
26
+ const res = await fetch(url, opts);
27
+ if (!res.ok) {
28
+ const err = await res.json().catch(() => ({error: res.statusText}));
29
+ throw new Error(err.error || res.statusText);
30
+ }
31
+ const text = await res.text();
32
+ return text ? JSON.parse(text) : {};
33
+ }
34
+
35
+ /**
36
+ * Escape HTML special characters to prevent XSS in render strings.
37
+ *
38
+ * @param {*} str
39
+ * @returns {string}
40
+ */
41
+ function esc(str) {
42
+ return String(str ?? '')
43
+ .replace(/&/g, '&amp;')
44
+ .replace(/</g, '&lt;')
45
+ .replace(/>/g, '&gt;')
46
+ .replace(/"/g, '&quot;');
47
+ }
48
+
49
+ const STATUS_VARIANT = {draft: 'badge-secondary', open: 'badge-success', closed: 'badge-warning'};
50
+
51
+ export const surveysView = {
52
+ templateUrl: '/plugins/surveys/admin/templates/surveys.html',
53
+
54
+ /**
55
+ * Mount the surveys list view.
56
+ *
57
+ * @param {object} $container - Domma-wrapped container element
58
+ * @returns {Promise<void>}
59
+ */
60
+ async onMount($container) {
61
+ let surveys = [];
62
+ let table = null;
63
+
64
+ async function load() {
65
+ try {
66
+ surveys = await api(`${BASE}/surveys`);
67
+ } catch (err) {
68
+ surveys = [];
69
+ E.toast(err?.message ?? 'Failed to load surveys.', {type: 'error'});
70
+ }
71
+ }
72
+
73
+ function build() {
74
+ const tableEl = $container.find('#surveys-table').get(0);
75
+ const emptyEl = $container.find('#surveys-empty').get(0);
76
+
77
+ if (!surveys.length) {
78
+ if (tableEl) tableEl.style.display = 'none';
79
+ if (emptyEl) emptyEl.style.display = 'block';
80
+ return;
81
+ }
82
+ if (emptyEl) emptyEl.style.display = 'none';
83
+ if (tableEl) tableEl.style.display = '';
84
+
85
+ if (table) {
86
+ table.setData(surveys);
87
+ Domma.icons.scan();
88
+ return;
89
+ }
90
+
91
+ // Render functions MUST return HTML strings (T.create contract).
92
+ // Action buttons carry data-slug + a class; DOMPurify (run by the
93
+ // table renderer) strips inline onclick, so clicks are wired via
94
+ // unnamespaced delegation on $container after the table mounts.
95
+ table = T.create($container.find('#surveys-table').get(0), {
96
+ data: surveys,
97
+ columns: [
98
+ {
99
+ key: 'title',
100
+ title: 'Title',
101
+ sortable: true,
102
+ render: (v) => `<strong>${esc(v)}</strong>`
103
+ },
104
+ {
105
+ key: 'status',
106
+ title: 'Status',
107
+ sortable: true,
108
+ render: (v) => {
109
+ const variant = STATUS_VARIANT[v] || 'badge-secondary';
110
+ return `<span class="badge ${variant}">${esc(v || 'draft')}</span>`;
111
+ }
112
+ },
113
+ {
114
+ key: 'mode',
115
+ title: 'Mode',
116
+ sortable: true,
117
+ render: (v) => esc(v || 'identified')
118
+ },
119
+ {
120
+ key: 'slug',
121
+ title: 'Actions',
122
+ sortable: false,
123
+ render: (slug) => {
124
+ const s = esc(slug);
125
+ return `<button class="btn btn-sm btn-outline btn-edit-survey" ` +
126
+ `data-slug="${s}" style="margin-right:4px;">` +
127
+ `<span data-icon="edit" data-icon-size="14"></span> Edit</button>` +
128
+ `<button class="btn btn-sm btn-outline btn-results-survey" ` +
129
+ `data-slug="${s}">` +
130
+ `<span data-icon="bar-chart" data-icon-size="14"></span> Results</button>`;
131
+ }
132
+ }
133
+ ],
134
+ emptyMessage: 'No surveys yet.'
135
+ });
136
+
137
+ Domma.icons.scan();
138
+ }
139
+
140
+ // Toolbar wiring
141
+ const newBtn = $container.find('#new-survey').get(0);
142
+ if (newBtn) newBtn.addEventListener('click', () => { location.hash = '#/plugins/surveys/new'; });
143
+
144
+ const audienceBtn = $container.find('#manage-audience').get(0);
145
+ if (audienceBtn) audienceBtn.addEventListener('click', () => { location.hash = '#/plugins/surveys/audience'; });
146
+
147
+ // Row-action delegation (proven pattern — see admin/js/views/api-tokens.js).
148
+ // Unnamespaced + idempotent via .off()/.on(); namespaced delegation is
149
+ // broken in this codebase.
150
+ $container.off('click', '.btn-edit-survey').on('click', '.btn-edit-survey', function () {
151
+ location.hash = '#/plugins/surveys/edit/' + this.getAttribute('data-slug');
152
+ });
153
+ $container.off('click', '.btn-results-survey').on('click', '.btn-results-survey', function () {
154
+ location.hash = '#/plugins/surveys/results/' + this.getAttribute('data-slug');
155
+ });
156
+
157
+ await load();
158
+ build();
159
+ Domma.icons.scan();
160
+ }
161
+ };
@@ -0,0 +1,13 @@
1
+ {
2
+ "slug": "surveys-contacts",
3
+ "title": "Survey Contacts",
4
+ "description": "Shared survey audience contacts.",
5
+ "plugin": "surveys",
6
+ "storage": {"adapter": "mongodb", "connection": "default"},
7
+ "fields": [
8
+ {"name": "name", "label": "Name", "type": "text", "required": false},
9
+ {"name": "email", "label": "Email", "type": "text", "required": true},
10
+ {"name": "groups","label": "Groups","type": "text", "required": false},
11
+ {"name": "meta", "label": "Meta", "type": "textarea", "required": false}
12
+ ]
13
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "slug": "surveys-groups",
3
+ "title": "Survey Groups",
4
+ "description": "Named survey audience groups.",
5
+ "plugin": "surveys",
6
+ "storage": {"adapter": "mongodb", "connection": "default"},
7
+ "fields": [
8
+ {"name": "name", "label": "Name", "type": "text", "required": true},
9
+ {"name": "description", "label": "Description", "type": "textarea", "required": false}
10
+ ]
11
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "slug": "surveys-invites",
3
+ "title": "Survey Invites",
4
+ "description": "Per-recipient invite ledger (token hashes + status).",
5
+ "plugin": "surveys",
6
+ "storage": {"adapter": "mongodb", "connection": "default"},
7
+ "fields": [
8
+ {"name": "surveyId", "label": "Survey", "type": "text", "required": true},
9
+ {"name": "contactId", "label": "Contact", "type": "text", "required": true},
10
+ {"name": "tokenHash", "label": "Token Hash", "type": "text", "required": true},
11
+ {"name": "status", "label": "Status", "type": "text", "required": true},
12
+ {"name": "sentAt", "label": "Sent At", "type": "text", "required": false},
13
+ {"name": "completedAt", "label": "Completed At","type": "text", "required": false},
14
+ {"name": "responseId", "label": "Response", "type": "text", "required": false}
15
+ ]
16
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "slug": "surveys-surveys",
3
+ "title": "Surveys",
4
+ "description": "Survey campaigns.",
5
+ "plugin": "surveys",
6
+ "storage": {"adapter": "mongodb", "connection": "default"},
7
+ "fields": [
8
+ {"name": "slug", "label": "Slug", "type": "text", "required": true},
9
+ {"name": "title", "label": "Title", "type": "text", "required": true},
10
+ {"name": "description", "label": "Description", "type": "textarea", "required": false},
11
+ {"name": "formSlug", "label": "Form", "type": "text", "required": true},
12
+ {"name": "groupIds", "label": "Groups", "type": "text", "required": false},
13
+ {"name": "mode", "label": "Mode", "type": "text", "required": true},
14
+ {"name": "status", "label": "Status", "type": "text", "required": true},
15
+ {"name": "opensAt", "label": "Opens At", "type": "text", "required": false},
16
+ {"name": "closesAt", "label": "Closes At", "type": "text", "required": false},
17
+ {"name": "reminderAfterDays", "label": "Reminder (days)", "type": "text", "required": false},
18
+ {"name": "intro", "label": "Intro (Markdown)", "type": "textarea", "required": false},
19
+ {"name": "footer", "label": "Footer (Markdown)", "type": "textarea", "required": false},
20
+ {"name": "thankYou", "label": "Thank-you message", "type": "textarea", "required": false},
21
+ {"name": "inviteEmail", "label": "Invite Email", "type": "textarea", "required": false}
22
+ ]
23
+ }
@@ -0,0 +1,8 @@
1
+ export default {
2
+ // Pro: Mongo when config/connections.json exists; degrades to file otherwise.
3
+ storage: {adapter: 'mongodb', connection: 'default'},
4
+ // Service token (dcms_...) the external scheduler presents to /due-work + /run.
5
+ // Empty disables the scheduler endpoints (403). Set in config/plugins.json.
6
+ schedulerToken: '',
7
+ fromName: 'Surveys'
8
+ };
@@ -0,0 +1,169 @@
1
+ import defaultConfig from './config.js';
2
+ import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
3
+ import {createCampaigns} from './lib/campaigns.js';
4
+ import {createAudience} from './lib/audience.js';
5
+ import {createLedger} from './lib/ledger.js';
6
+ import {createTransport, sendEmail} from '../../server/services/email.js';
7
+ import {getConfig} from '../../server/config.js';
8
+ import {readForm} from '../../server/services/forms.js';
9
+ import {runSend} from './lib/sending.js';
10
+ import {aggregateField, campaignHealth, toCsv} from './lib/stats.js';
11
+ import {registerPluginResource} from '../../server/services/permissionRegistry.js';
12
+
13
+ const col = {createEntry, deleteEntry, getEntry, listEntries, updateEntry};
14
+
15
+ // Register the `surveys` permission family (idempotent — no-op if already present).
16
+ // Custom roles are NOT back-filled; admins grant it in the role editor (same gotcha
17
+ // as Menus/Projects).
18
+ registerPluginResource({
19
+ key: 'surveys',
20
+ label: 'Surveys',
21
+ description: 'Create and manage survey campaigns, audiences, and results.',
22
+ icon: 'clipboard-list',
23
+ group: 'Plugins',
24
+ actions: [
25
+ {key: 'read', label: 'View', description: 'View surveys, audience, and results'},
26
+ {key: 'create', label: 'Create', description: 'Create surveys, contacts, and groups'},
27
+ {key: 'update', label: 'Edit', description: 'Edit surveys and send invitations'},
28
+ {key: 'delete', label: 'Delete', description: 'Delete surveys, contacts, and groups'}
29
+ ]
30
+ }, 'surveys');
31
+
32
+ export default async function surveysPlugin(fastify, options) {
33
+ const {authenticate} = options.auth;
34
+ const config = {...defaultConfig, ...(options.settings || {})};
35
+ const campaigns = createCampaigns(col);
36
+ const audience = createAudience(col);
37
+ const ledger = createLedger(col);
38
+ const auth = {preHandler: [authenticate]};
39
+
40
+ // ---- [survey] embed shortcode ----
41
+ // Drops a survey mount point into any page. The client (survey.mjs) resolves
42
+ // the form slug at runtime, so `data-form-slug` may safely be left empty.
43
+ // Guarded so the registration never throws when hooks are absent (test harness).
44
+ if (options.hooks?.registerShortcode) {
45
+ options.hooks.registerShortcode('survey', async (attrStr, body, context) => {
46
+ const attrs = context.parseShortcodeAttrs(attrStr);
47
+ const slug = attrs.slug || '';
48
+ const esc = context.escapeAttr || ((v) => String(v ?? ''));
49
+ let formSlug = '';
50
+ try {
51
+ const survey = slug ? await campaigns.getBySlug(slug) : null;
52
+ formSlug = survey?.data?.formSlug || '';
53
+ } catch {
54
+ formSlug = '';
55
+ }
56
+ return `<div class="survey-mount" data-survey-slug="${esc(slug)}" data-form-slug="${esc(formSlug)}"></div>`
57
+ + `<script type="module" src="/plugins/surveys/public/survey.mjs"></script>`;
58
+ });
59
+ }
60
+
61
+ // ---- Surveys (campaigns) ----
62
+ fastify.get('/surveys', auth, async () => (await campaigns.all()).map(e => ({id: e.id, ...e.data})));
63
+
64
+ fastify.post('/surveys', auth, async (req, reply) => {
65
+ try {
66
+ const s = await campaigns.createSurvey(req.body ?? {});
67
+ return reply.code(201).send({id: s.id, ...s.data});
68
+ } catch (err) { return reply.code(400).send({error: err.message}); }
69
+ });
70
+
71
+ fastify.get('/surveys/:slug', auth, async (req, reply) => {
72
+ const s = await campaigns.getBySlug(req.params.slug);
73
+ if (!s) return reply.code(404).send({error: 'Survey not found'});
74
+ return {id: s.id, ...s.data};
75
+ });
76
+
77
+ fastify.put('/surveys/:slug', auth, async (req, reply) => {
78
+ const s = await campaigns.getBySlug(req.params.slug);
79
+ if (!s) return reply.code(404).send({error: 'Survey not found'});
80
+ const updated = await campaigns.updateSurvey(s.id, req.body ?? {});
81
+ return {id: updated.id, ...updated.data};
82
+ });
83
+
84
+ // ---- Audience: contacts ----
85
+ fastify.get('/contacts', auth, async () => audience.listContacts());
86
+ fastify.post('/contacts', auth, async (req, reply) => {
87
+ const {name, email, groups} = req.body ?? {};
88
+ if (!email) return reply.code(400).send({error: 'email is required'});
89
+ const e = await createEntry('surveys-contacts', {name: name ?? '', email, groups: Array.isArray(groups) ? groups : []}, {source: 'admin'});
90
+ return reply.code(201).send({id: e.id, ...e.data});
91
+ });
92
+ fastify.delete('/contacts/:id', auth, async (req, reply) => {
93
+ await deleteEntry('surveys-contacts', req.params.id).catch(() => {});
94
+ return {ok: true};
95
+ });
96
+
97
+ // ---- Audience: groups ----
98
+ fastify.get('/groups', auth, async () => {
99
+ const {entries} = await listEntries('surveys-groups', {limit: 10000});
100
+ return entries.map(e => ({id: e.id, name: e.data.name, description: e.data.description ?? ''}));
101
+ });
102
+ fastify.post('/groups', auth, async (req, reply) => {
103
+ const {name, description} = req.body ?? {};
104
+ if (!name) return reply.code(400).send({error: 'name is required'});
105
+ const e = await createEntry('surveys-groups', {name, description: description ?? ''}, {source: 'admin'});
106
+ return reply.code(201).send({id: e.id, ...e.data});
107
+ });
108
+
109
+ // ---- Import from Contacts plugin ----
110
+ fastify.post('/import-from-contacts', auth, async (req, reply) => {
111
+ const {entries} = await listEntries('contacts-contacts', {limit: 100000}).catch(() => ({entries: []}));
112
+ const source = entries.map(e => ({name: e.data.name, email: e.data.email, groups: e.data.groups}));
113
+ const result = await audience.importFromContacts(source);
114
+ return reply.send(result);
115
+ });
116
+
117
+ fastify.post('/surveys/:slug/send', auth, async (req, reply) => {
118
+ const survey = await campaigns.getBySlug(req.params.slug);
119
+ if (!survey) return reply.code(404).send({error: 'Survey not found'});
120
+ const mode = (req.body?.audience === 'non-responders') ? 'non-responders' : 'all';
121
+ const smtp = getConfig('site').smtp || {};
122
+ const transport = await createTransport(smtp);
123
+ // Survey links in emails must be ABSOLUTE. Prefer the configured canonical
124
+ // site URL; fall back to the origin of this admin request so links work
125
+ // out of the box even when config/site.json has no `url`.
126
+ const baseUrl = (getConfig('site').url || '').replace(/\/$/, '')
127
+ || `${req.protocol}://${req.headers.host}`;
128
+ const {results} = await runSend({
129
+ survey, mode, audience, ledger, transport, sendEmail, smtp,
130
+ baseUrl, fromName: config.fromName, nowIso: new Date().toISOString()
131
+ });
132
+ return reply.send({results});
133
+ });
134
+
135
+ fastify.get('/surveys/:slug/stats', auth, async (req, reply) => {
136
+ const survey = await campaigns.getBySlug(req.params.slug);
137
+ if (!survey) return reply.code(404).send({error: 'Survey not found'});
138
+ const form = await readForm(survey.data.formSlug).catch(() => null);
139
+ const {entries} = await listEntries(survey.data.formSlug, {limit: 100000}).catch(() => ({entries: []}));
140
+ // Scope to THIS survey's responses (tagged `survey:<slug>` at submit time),
141
+ // so direct submissions to a shared form are excluded.
142
+ const tag = `survey:${survey.data.slug}`;
143
+ const rows = entries.filter(e => e.meta?.source === tag).map(e => e.data);
144
+ const fields = (form?.fields || [])
145
+ .filter(f => !['page-break', 'spacer', 'heading', 'paragraph', 'html'].includes(f.type))
146
+ .map(f => aggregateField(f, rows));
147
+ const health = campaignHealth(await ledger.invitesForSurvey(survey.id));
148
+ return reply.send({health, fields});
149
+ });
150
+
151
+ fastify.get('/surveys/:slug/export', auth, async (req, reply) => {
152
+ const survey = await campaigns.getBySlug(req.params.slug);
153
+ if (!survey) return reply.code(404).send({error: 'Survey not found'});
154
+ const form = await readForm(survey.data.formSlug).catch(() => null);
155
+ const cols = (form?.fields || []).map(f => f.name).filter(Boolean);
156
+ const {entries} = await listEntries(survey.data.formSlug, {limit: 100000}).catch(() => ({entries: []}));
157
+ // Export only this survey's tagged responses, not stray form submissions.
158
+ const tag = `survey:${survey.data.slug}`;
159
+ const rows = entries.filter(e => e.meta?.source === tag).map(e => e.data);
160
+ if ((req.query.format || 'csv') === 'json') {
161
+ return reply.header('Content-Disposition', `attachment; filename="${survey.data.slug}.json"`).send(rows);
162
+ }
163
+ return reply.header('Content-Type', 'text/csv')
164
+ .header('Content-Disposition', `attachment; filename="${survey.data.slug}.csv"`)
165
+ .send(toCsv(rows, cols));
166
+ });
167
+
168
+ fastify.surveys = {campaigns, audience, ledger, col, config};
169
+ }