domma-cms 0.66.0 → 0.69.4

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 (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -1,43 +0,0 @@
1
- const S = 'surveys-surveys';
2
- const MODES = new Set(['identified', 'anonymous']);
3
- const STATUSES = new Set(['draft', 'open', 'closed']);
4
-
5
- export function createCampaigns(col) {
6
- async function all() { const {entries} = await col.listEntries(S, {limit: 100000}); return entries; }
7
- async function getBySlug(slug) { return (await all()).find(e => e.data.slug === slug) ?? null; }
8
-
9
- async function createSurvey(input) {
10
- if (!input.slug || !input.title || !input.formSlug) throw new Error('slug, title and formSlug are required');
11
- if (await getBySlug(input.slug)) throw new Error(`A survey with slug "${input.slug}" already exists`);
12
- return col.createEntry(S, {
13
- slug: input.slug,
14
- title: input.title,
15
- description: input.description ?? '',
16
- formSlug: input.formSlug,
17
- groupIds: Array.isArray(input.groupIds) ? input.groupIds : [],
18
- mode: MODES.has(input.mode) ? input.mode : 'identified',
19
- status: STATUSES.has(input.status) ? input.status : 'draft',
20
- opensAt: input.opensAt ?? null,
21
- closesAt: input.closesAt ?? null,
22
- reminderAfterDays: Number(input.reminderAfterDays) || 7,
23
- intro: input.intro ?? '',
24
- footer: input.footer ?? '',
25
- thankYou: input.thankYou ?? '',
26
- inviteEmail: input.inviteEmail ?? {subject: '', body: ''}
27
- }, {source: 'admin'});
28
- }
29
-
30
- async function updateSurvey(id, patch) {
31
- const entry = await col.getEntry(S, id);
32
- if (!entry) throw new Error('Survey not found');
33
- const merged = {...entry.data};
34
- for (const k of ['title', 'description', 'formSlug', 'groupIds', 'opensAt', 'closesAt', 'reminderAfterDays', 'intro', 'footer', 'thankYou', 'inviteEmail']) {
35
- if (k in patch) merged[k] = patch[k];
36
- }
37
- if (patch.mode && MODES.has(patch.mode)) merged.mode = patch.mode;
38
- if (patch.status && STATUSES.has(patch.status)) merged.status = patch.status;
39
- return col.updateEntry(S, id, merged);
40
- }
41
-
42
- return {all, getBySlug, createSurvey, updateSurvey};
43
- }
@@ -1,110 +0,0 @@
1
- import {generateToken, hashToken, normaliseCode} from './tokens.js';
2
-
3
- const SLUG = 'surveys-invites';
4
-
5
- /**
6
- * Ledger over the surveys-invites collection.
7
- * @param {{createEntry,listEntries,getEntry,updateEntry}} col collections service
8
- */
9
- export function createLedger(col) {
10
- async function invitesForSurvey(surveyId) {
11
- const {entries} = await col.listEntries(SLUG, {limit: 100000});
12
- return entries.filter(e => e.data.surveyId === surveyId);
13
- }
14
-
15
- /** Issue pending invites for contacts that don't already have one. Returns [{id, contactId, token}]. */
16
- async function issueInvites(surveyId, contactIds) {
17
- const existing = await invitesForSurvey(surveyId);
18
- const seen = new Set(existing.map(e => e.data.contactId));
19
- const out = [];
20
- for (const contactId of contactIds) {
21
- if (seen.has(contactId)) continue;
22
- const token = generateToken();
23
- const entry = await col.createEntry(SLUG, {
24
- surveyId, contactId,
25
- tokenHash: hashToken(normaliseCode(token)),
26
- status: 'pending', sentAt: null, completedAt: null, responseId: null
27
- }, {source: 'surveys'});
28
- out.push({id: entry.id, contactId, token});
29
- seen.add(contactId);
30
- }
31
- return out;
32
- }
33
-
34
- /**
35
- * Ensure every listed contact has a FRESH token to email. New contacts get a
36
- * created invite; existing non-completed invites are re-issued with a new
37
- * token (so re-sends and reminders carry a working personalised link);
38
- * contacts who already completed the survey are skipped. The previous token
39
- * for a re-issued invite stops working - latest link wins.
40
- *
41
- * Returns [{id, contactId, token}] for the invites to email.
42
- */
43
- async function issueOrReissue(surveyId, contactIds) {
44
- const existing = await invitesForSurvey(surveyId);
45
- const byContact = new Map(existing.map(e => [e.data.contactId, e]));
46
- const out = [];
47
- for (const contactId of contactIds) {
48
- const ex = byContact.get(contactId);
49
- if (ex && ex.data.status === 'completed') continue;
50
- const token = generateToken();
51
- const tokenHash = hashToken(normaliseCode(token));
52
- if (ex) {
53
- await col.updateEntry(SLUG, ex.id, {...ex.data, tokenHash});
54
- out.push({id: ex.id, contactId, token});
55
- } else {
56
- const entry = await col.createEntry(SLUG, {
57
- surveyId, contactId, tokenHash,
58
- status: 'pending', sentAt: null, completedAt: null, responseId: null
59
- }, {source: 'surveys'});
60
- out.push({id: entry.id, contactId, token});
61
- }
62
- }
63
- return out;
64
- }
65
-
66
- /** Find an invite by submitted code (any format). Null if unknown. */
67
- async function findByCode(surveyId, code) {
68
- const hash = hashToken(normaliseCode(code));
69
- const invites = await invitesForSurvey(surveyId);
70
- return invites.find(e => e.data.tokenHash === hash) ?? null;
71
- }
72
-
73
- /** Mark sent / bounced. */
74
- async function markSent(inviteId, ok, nowIso) {
75
- const entry = await col.getEntry(SLUG, inviteId);
76
- if (!entry) return null;
77
- return col.updateEntry(SLUG, inviteId, {
78
- ...entry.data,
79
- status: ok ? 'sent' : 'bounced',
80
- sentAt: ok ? nowIso : entry.data.sentAt
81
- });
82
- }
83
-
84
- /** Mark completed with the stored response id. */
85
- async function markCompleted(inviteId, responseId, nowIso) {
86
- const entry = await col.getEntry(SLUG, inviteId);
87
- if (!entry) return null;
88
- return col.updateEntry(SLUG, inviteId, {
89
- ...entry.data, status: 'completed', completedAt: nowIso, responseId: responseId ?? null
90
- });
91
- }
92
-
93
- /** Invites that are neither completed nor bounced. */
94
- async function outstanding(surveyId) {
95
- const invites = await invitesForSurvey(surveyId);
96
- return invites.filter(e => e.data.status !== 'completed' && e.data.status !== 'bounced');
97
- }
98
-
99
- /** Sent invites, not yet completed, whose sentAt is older than afterDays before now. */
100
- async function dueForReminder(surveyId, afterDays, now) {
101
- const cutoff = now.getTime() - Number(afterDays) * 86400000;
102
- const invites = await invitesForSurvey(surveyId);
103
- return invites.filter(e =>
104
- e.data.status === 'sent' &&
105
- e.data.sentAt && new Date(e.data.sentAt).getTime() <= cutoff
106
- );
107
- }
108
-
109
- return {invitesForSurvey, issueInvites, issueOrReissue, findByCode, markSent, markCompleted, outstanding, dueForReminder};
110
- }
@@ -1,106 +0,0 @@
1
- import {formatCode} from './tokens.js';
2
-
3
- function escapeHtml(s) {
4
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
5
- }
6
-
7
- /** Escape a value for use inside a double-quoted HTML attribute (e.g. href). */
8
- function escapeAttr(s) {
9
- return String(s ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
10
- }
11
-
12
- /**
13
- * Render one invite's subject + plain-text + HTML from a template and a link.
14
- *
15
- * The HTML version turns each {{link}} into a clickable "Take the survey" button
16
- * (the raw URL was previously emitted as inert escaped text - unclickable). All
17
- * non-link copy is escaped so user-authored bodies can't inject markup. The
18
- * plain-text version keeps the raw URL.
19
- */
20
- export function renderInvite(template, contact, link) {
21
- const name = contact.name || 'there';
22
- const subjectTpl = (template.subject && template.subject.trim()) || 'You are invited to a survey';
23
- const bodyTpl = (template.body && template.body.trim())
24
- || "Hi {{name}},\n\nWe'd really value your feedback - it only takes a minute.\n\n{{link}}";
25
-
26
- const subject = subjectTpl.replaceAll('{{name}}', name).replaceAll('{{link}}', link);
27
- const text = bodyTpl.replaceAll('{{name}}', name).replaceAll('{{link}}', link);
28
-
29
- const button = `<p style="margin:24px 0;"><a href="${escapeAttr(link)}" `
30
- + `style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;`
31
- + `padding:12px 24px;border-radius:6px;font-weight:600;font-size:15px;">Take the survey</a></p>`;
32
-
33
- const bodyHtml = bodyTpl
34
- .split('{{link}}')
35
- .map((seg) => escapeHtml(seg.replaceAll('{{name}}', name)).replace(/\n/g, '<br>'))
36
- .join(button);
37
-
38
- const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;`
39
- + `max-width:560px;margin:0 auto;padding:24px;color:#1f2937;line-height:1.6;font-size:15px;">`
40
- + bodyHtml
41
- + `<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">`
42
- + `<p style="font-size:12px;color:#6b7280;">If the button doesn't work, paste this link into your browser:<br>`
43
- + `<a href="${escapeAttr(link)}" style="color:#2563eb;">${escapeHtml(link)}</a></p>`
44
- + `</div>`;
45
-
46
- return {subject, text, html};
47
- }
48
-
49
- /**
50
- * Send to each recipient; never fails wholesale. Returns [{inviteId, ok}].
51
- * @param {Array<{invite,contact,token}>} recipients
52
- * @param {{subject,body}} template
53
- * @param {object} deps {sendEmail, transport, from, fromName, baseUrl, surveySlug}
54
- */
55
- export async function sendBatch(recipients, template, deps) {
56
- const {sendEmail, transport, from, fromName, baseUrl, surveySlug} = deps;
57
- const results = [];
58
- for (const r of recipients) {
59
- const link = `${baseUrl}/survey/${surveySlug}?t=${formatCode(r.token)}`;
60
- const msg = renderInvite(template, r.contact, link);
61
- try {
62
- await sendEmail(transport, {from, fromName, to: r.contact.email, subject: msg.subject, html: msg.html, text: msg.text});
63
- results.push({inviteId: r.invite.id, ok: true});
64
- } catch {
65
- results.push({inviteId: r.invite.id, ok: false});
66
- }
67
- }
68
- return results;
69
- }
70
-
71
- /**
72
- * Resolve recipients for a survey and send them invites, flipping the ledger.
73
- * Shared by the admin /send route and the scheduler /run route.
74
- * @param {object} ctx {survey, mode, audience, ledger, transport, sendEmail, smtp, baseUrl, fromName, nowIso}
75
- * @returns {Promise<{results: Array<{inviteId,ok}>}>}
76
- */
77
- export async function runSend(ctx) {
78
- const {survey, mode, audience, ledger, transport, sendEmail, smtp, baseUrl, fromName, nowIso} = ctx;
79
- const groupNames = Array.isArray(survey.data.groupIds) ? survey.data.groupIds : [];
80
- const contactIds = await audience.contactIdsForGroups(groupNames);
81
- const contacts = (await audience.listContacts()).filter(c => contactIds.includes(c.id));
82
-
83
- // Who to target: 'non-responders' narrows to contacts whose invite is still
84
- // outstanding (invited, not completed/bounced); 'all' targets everyone.
85
- let targetIds = contactIds;
86
- if (mode === 'non-responders') {
87
- const outstanding = new Set((await ledger.outstanding(survey.id)).map(e => e.data.contactId));
88
- targetIds = contactIds.filter(id => outstanding.has(id));
89
- }
90
-
91
- // (Re)issue a fresh token for each target so re-sends and reminders carry a
92
- // working personalised link; contacts who already completed are skipped.
93
- const issued = await ledger.issueOrReissue(survey.id, targetIds);
94
- const tokenByContact = new Map(issued.map(i => [i.contactId, i]));
95
- const recipients = contacts
96
- .filter(c => tokenByContact.has(c.id))
97
- .map(c => {
98
- const inv = tokenByContact.get(c.id);
99
- return {invite: {id: inv.id}, contact: c, token: inv.token};
100
- });
101
- const results = await sendBatch(recipients, survey.data.inviteEmail || {}, {
102
- sendEmail, transport, from: smtp.fromAddress, fromName: smtp.fromName || fromName, baseUrl, surveySlug: survey.data.slug
103
- });
104
- for (const r of results) await ledger.markSent(r.inviteId, r.ok, nowIso);
105
- return {results};
106
- }
@@ -1,62 +0,0 @@
1
- const CHOICE = new Set(['select', 'radio', 'checkbox', 'dropdown']);
2
- const RATING = new Set(['rating', 'number', 'range']);
3
-
4
- /** Aggregate one field across response rows into a normalised distribution. */
5
- export function aggregateField(field, rows) {
6
- const label = field.label || field.name;
7
- if (RATING.has(field.type)) {
8
- const nums = rows.map(r => Number(r[field.name])).filter(n => !Number.isNaN(n));
9
- const average = nums.length ? Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 100) / 100 : 0;
10
- const counts = tally(rows.map(r => String(r[field.name] ?? '')).filter(Boolean));
11
- return {field: field.name, label, type: 'rating', average, items: toItems(counts)};
12
- }
13
- if (CHOICE.has(field.type)) {
14
- const counts = tally(rows.flatMap(r => Array.isArray(r[field.name]) ? r[field.name] : [r[field.name]]).filter(Boolean));
15
- return {field: field.name, label, type: 'choice', items: toItems(counts)};
16
- }
17
- return {field: field.name, label, type: 'text', responses: rows.map(r => r[field.name]).filter(Boolean)};
18
- }
19
-
20
- function tally(values) {
21
- const m = new Map();
22
- for (const v of values) m.set(v, (m.get(v) || 0) + 1);
23
- return m;
24
- }
25
-
26
- function toItems(counts) {
27
- const total = [...counts.values()].reduce((a, b) => a + b, 0) || 1;
28
- return [...counts.entries()].map(([k, v]) => ({label: String(k), value: v, pct: Math.round((v / total) * 100)}));
29
- }
30
-
31
- /**
32
- * THE CHARTS SEAM. Input shape [{label,value,pct}] is the exact contract a
33
- * future framework charts() call will accept. Swap this body for that call
34
- * when the component lands. See docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md.
35
- */
36
- export function renderDistribution(items) {
37
- return `<div class="survey-dist">` + items.map(i =>
38
- `<div class="survey-dist-row"><span class="survey-dist-label">${escapeHtml(i.label)}</span>` +
39
- `<span class="survey-dist-bar"><span class="survey-dist-fill" data-pct="${i.pct}" style="width:${i.pct}%"></span></span>` +
40
- `<span class="survey-dist-val">${i.value} (${i.pct}%)</span></div>`
41
- ).join('') + `</div>`;
42
- }
43
-
44
- export function campaignHealth(invites) {
45
- const invited = invites.length;
46
- const completed = invites.filter(i => i.data.status === 'completed').length;
47
- return {invited, completed, responseRate: invited ? Math.round((completed / invited) * 100) : 0};
48
- }
49
-
50
- export function toCsv(rows, columns) {
51
- const esc = (v) => {
52
- const s = String(v ?? '');
53
- return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
54
- };
55
- const head = columns.join(',');
56
- const body = rows.map(r => columns.map(c => esc(r[c])).join(',')).join('\n');
57
- return `${head}\n${body}`;
58
- }
59
-
60
- function escapeHtml(s) {
61
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
62
- }
@@ -1,95 +0,0 @@
1
- import {createRequire} from 'module';
2
-
3
- const require = createRequire(import.meta.url);
4
- const FormLogicEngine = require('../../../public/js/form-logic-engine.js');
5
-
6
- class SurveyError extends Error {
7
- constructor(message, code = 400) { super(message); this.statusCode = code; }
8
- }
9
-
10
- function withinWindow(data, now) {
11
- const t = new Date(now).getTime();
12
- if (data.opensAt && t < new Date(data.opensAt).getTime()) return false;
13
- if (data.closesAt && t > new Date(data.closesAt).getTime()) return false;
14
- return true;
15
- }
16
-
17
- /** Validate visible fields with the same engine core uses. Throws SurveyError. */
18
- function validate(form, body) {
19
- const values = {};
20
- for (const f of form.fields || []) {
21
- if (['page-break', 'spacer'].includes(f.type)) continue;
22
- const v = body[f.name];
23
- values[f.name] = typeof v === 'string' ? v.trim() : (v ?? '');
24
- }
25
- const missing = [];
26
- const errors = [];
27
- for (const f of form.fields || []) {
28
- if (['page-break', 'spacer'].includes(f.type)) continue;
29
- if (FormLogicEngine.evaluateFieldVisibility(f, values) === 'hidden') continue;
30
- const val = values[f.name];
31
- const empty = !val?.toString().trim();
32
- if (FormLogicEngine.evaluateFieldRequirement(f, values) && empty) missing.push(f.label || f.name);
33
- else if (!empty) {
34
- const e = FormLogicEngine.validateField(f, val, values);
35
- if (e.length) errors.push(e[0].message);
36
- }
37
- }
38
- if (missing.length || errors.length) {
39
- const parts = [];
40
- if (missing.length) parts.push(`Required fields missing: ${missing.join(', ')}`);
41
- if (errors.length) parts.push(errors.join('; '));
42
- throw new SurveyError(`${parts.join('. ')}.`);
43
- }
44
- return values;
45
- }
46
-
47
- /**
48
- * Process a tokenised survey submission.
49
- * @param {string} surveySlug
50
- * @param {string} code submitted invite code (any format)
51
- * @param {object} body answer values keyed by field name
52
- * @param {object} deps {col, ledger, getSurveyBySlug, readForm, getCollection, sendCompletionEmail, now}
53
- * @returns {Promise<{ok:true, message:string}>}
54
- */
55
- export async function processSurveySubmission(surveySlug, code, body, deps) {
56
- const {col, ledger, getSurveyBySlug, readForm, getCollection, sendCompletionEmail, now} = deps;
57
-
58
- const survey = await getSurveyBySlug(surveySlug);
59
- if (!survey) throw new SurveyError('Survey not found.', 404);
60
- const s = survey.data;
61
- if (s.status !== 'open') throw new SurveyError('This survey has closed.', 410);
62
- if (!withinWindow(s, now())) throw new SurveyError('This survey has closed.', 410);
63
-
64
- const invite = await ledger.findByCode(survey.id, code);
65
- if (!invite) throw new SurveyError('That code is invalid for this survey.', 403);
66
- if (invite.data.status === 'completed') throw new SurveyError('This survey has already been completed for that code.', 409);
67
-
68
- const form = await readForm(s.formSlug);
69
- const values = validate(form, body);
70
-
71
- const targetSlug = s.formSlug;
72
- const targetCollection = await getCollection(targetSlug);
73
- if (!targetCollection) throw new SurveyError('Survey form is not configured correctly.', 500);
74
- const data = {};
75
- for (const f of form.fields || []) {
76
- if (['page-break', 'spacer'].includes(f.type)) continue;
77
- if (values[f.name] !== undefined) data[f.name] = values[f.name];
78
- }
79
- // NOTE: contactId / token are deliberately NOT written here - anonymity is intrinsic.
80
- const entry = await col.createEntry(targetSlug, data, {source: `survey:${surveySlug}`});
81
-
82
- const responseId = s.mode === 'identified' ? entry.id : null;
83
- await ledger.markCompleted(invite.id, responseId, now());
84
-
85
- await sendCompletionEmail(form, data).catch((err) => console.warn('[surveys] completion email failed:', err.message));
86
-
87
- // Prefer the survey's own thank-you message over the underlying form's
88
- // successMessage, so respondents see a survey-specific confirmation rather
89
- // than the generic form response.
90
- const thankYou = (s.thankYou && String(s.thankYou).trim())
91
- || 'Thank you - your survey response has been recorded.';
92
- return {ok: true, message: thankYou};
93
- }
94
-
95
- export {SurveyError};
@@ -1,28 +0,0 @@
1
- import crypto from 'crypto';
2
-
3
- const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no O,0,I,1,L
4
- const LENGTH = 8;
5
-
6
- /** Generate an 8-char unambiguous uppercase token. */
7
- export function generateToken() {
8
- const bytes = crypto.randomBytes(LENGTH);
9
- let out = '';
10
- for (let i = 0; i < LENGTH; i++) out += ALPHABET[bytes[i] % ALPHABET.length];
11
- return out;
12
- }
13
-
14
- /** SHA-256 hex of a normalised code. The plaintext is never stored. */
15
- export function hashToken(code) {
16
- return crypto.createHash('sha256').update(String(code)).digest('hex');
17
- }
18
-
19
- /** Display form: ABCD-EFGH. */
20
- export function formatCode(token) {
21
- const t = String(token).toUpperCase();
22
- return t.length === 8 ? `${t.slice(0, 4)}-${t.slice(4)}` : t;
23
- }
24
-
25
- /** Canonical form for hashing/lookup: strip non-alphanumerics, uppercase. */
26
- export function normaliseCode(input) {
27
- return String(input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
28
- }
@@ -1,174 +0,0 @@
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
- // Email transport is injectable (defaults to the real SMTP service). Tests
36
- // pass a deterministic, network-free stub so the send path can't time out on
37
- // real SMTP / Ethereal account creation and mark every invite "bounced".
38
- const makeTransport = options.email?.createTransport || createTransport;
39
- const sendMail = options.email?.sendEmail || sendEmail;
40
- const campaigns = createCampaigns(col);
41
- const audience = createAudience(col);
42
- const ledger = createLedger(col);
43
- const auth = {preHandler: [authenticate]};
44
-
45
- // ---- [survey] embed shortcode ----
46
- // Drops a survey mount point into any page. The client (survey.mjs) resolves
47
- // the form slug at runtime, so `data-form-slug` may safely be left empty.
48
- // Guarded so the registration never throws when hooks are absent (test harness).
49
- if (options.hooks?.registerShortcode) {
50
- options.hooks.registerShortcode('survey', async (attrStr, body, context) => {
51
- const attrs = context.parseShortcodeAttrs(attrStr);
52
- const slug = attrs.slug || '';
53
- const esc = context.escapeAttr || ((v) => String(v ?? ''));
54
- let formSlug = '';
55
- try {
56
- const survey = slug ? await campaigns.getBySlug(slug) : null;
57
- formSlug = survey?.data?.formSlug || '';
58
- } catch {
59
- formSlug = '';
60
- }
61
- return `<div class="survey-mount" data-survey-slug="${esc(slug)}" data-form-slug="${esc(formSlug)}"></div>`
62
- + `<script type="module" src="/plugins/surveys/public/survey.mjs"></script>`;
63
- });
64
- }
65
-
66
- // ---- Surveys (campaigns) ----
67
- fastify.get('/surveys', auth, async () => (await campaigns.all()).map(e => ({id: e.id, ...e.data})));
68
-
69
- fastify.post('/surveys', auth, async (req, reply) => {
70
- try {
71
- const s = await campaigns.createSurvey(req.body ?? {});
72
- return reply.code(201).send({id: s.id, ...s.data});
73
- } catch (err) { return reply.code(400).send({error: err.message}); }
74
- });
75
-
76
- fastify.get('/surveys/:slug', auth, async (req, reply) => {
77
- const s = await campaigns.getBySlug(req.params.slug);
78
- if (!s) return reply.code(404).send({error: 'Survey not found'});
79
- return {id: s.id, ...s.data};
80
- });
81
-
82
- fastify.put('/surveys/:slug', auth, async (req, reply) => {
83
- const s = await campaigns.getBySlug(req.params.slug);
84
- if (!s) return reply.code(404).send({error: 'Survey not found'});
85
- const updated = await campaigns.updateSurvey(s.id, req.body ?? {});
86
- return {id: updated.id, ...updated.data};
87
- });
88
-
89
- // ---- Audience: contacts ----
90
- fastify.get('/contacts', auth, async () => audience.listContacts());
91
- fastify.post('/contacts', auth, async (req, reply) => {
92
- const {name, email, groups} = req.body ?? {};
93
- if (!email) return reply.code(400).send({error: 'email is required'});
94
- const e = await createEntry('surveys-contacts', {name: name ?? '', email, groups: Array.isArray(groups) ? groups : []}, {source: 'admin'});
95
- return reply.code(201).send({id: e.id, ...e.data});
96
- });
97
- fastify.delete('/contacts/:id', auth, async (req, reply) => {
98
- await deleteEntry('surveys-contacts', req.params.id).catch(() => {});
99
- return {ok: true};
100
- });
101
-
102
- // ---- Audience: groups ----
103
- fastify.get('/groups', auth, async () => {
104
- const {entries} = await listEntries('surveys-groups', {limit: 10000});
105
- return entries.map(e => ({id: e.id, name: e.data.name, description: e.data.description ?? ''}));
106
- });
107
- fastify.post('/groups', auth, async (req, reply) => {
108
- const {name, description} = req.body ?? {};
109
- if (!name) return reply.code(400).send({error: 'name is required'});
110
- const e = await createEntry('surveys-groups', {name, description: description ?? ''}, {source: 'admin'});
111
- return reply.code(201).send({id: e.id, ...e.data});
112
- });
113
-
114
- // ---- Import from Contacts plugin ----
115
- fastify.post('/import-from-contacts', auth, async (req, reply) => {
116
- const {entries} = await listEntries('contacts-contacts', {limit: 100000}).catch(() => ({entries: []}));
117
- const source = entries.map(e => ({name: e.data.name, email: e.data.email, groups: e.data.groups}));
118
- const result = await audience.importFromContacts(source);
119
- return reply.send(result);
120
- });
121
-
122
- fastify.post('/surveys/:slug/send', auth, async (req, reply) => {
123
- const survey = await campaigns.getBySlug(req.params.slug);
124
- if (!survey) return reply.code(404).send({error: 'Survey not found'});
125
- const mode = (req.body?.audience === 'non-responders') ? 'non-responders' : 'all';
126
- const smtp = getConfig('site').smtp || {};
127
- const transport = await makeTransport(smtp);
128
- // Survey links in emails must be ABSOLUTE. Prefer the configured canonical
129
- // site URL; fall back to the origin of this admin request so links work
130
- // out of the box even when config/site.json has no `url`.
131
- const baseUrl = (getConfig('site').url || '').replace(/\/$/, '')
132
- || `${req.protocol}://${req.headers.host}`;
133
- const {results} = await runSend({
134
- survey, mode, audience, ledger, transport, sendEmail: sendMail, smtp,
135
- baseUrl, fromName: config.fromName, nowIso: new Date().toISOString()
136
- });
137
- return reply.send({results});
138
- });
139
-
140
- fastify.get('/surveys/:slug/stats', auth, async (req, reply) => {
141
- const survey = await campaigns.getBySlug(req.params.slug);
142
- if (!survey) return reply.code(404).send({error: 'Survey not found'});
143
- const form = await readForm(survey.data.formSlug).catch(() => null);
144
- const {entries} = await listEntries(survey.data.formSlug, {limit: 100000}).catch(() => ({entries: []}));
145
- // Scope to THIS survey's responses (tagged `survey:<slug>` at submit time),
146
- // so direct submissions to a shared form are excluded.
147
- const tag = `survey:${survey.data.slug}`;
148
- const rows = entries.filter(e => e.meta?.source === tag).map(e => e.data);
149
- const fields = (form?.fields || [])
150
- .filter(f => !['page-break', 'spacer', 'heading', 'paragraph', 'html'].includes(f.type))
151
- .map(f => aggregateField(f, rows));
152
- const health = campaignHealth(await ledger.invitesForSurvey(survey.id));
153
- return reply.send({health, fields});
154
- });
155
-
156
- fastify.get('/surveys/:slug/export', auth, async (req, reply) => {
157
- const survey = await campaigns.getBySlug(req.params.slug);
158
- if (!survey) return reply.code(404).send({error: 'Survey not found'});
159
- const form = await readForm(survey.data.formSlug).catch(() => null);
160
- const cols = (form?.fields || []).map(f => f.name).filter(Boolean);
161
- const {entries} = await listEntries(survey.data.formSlug, {limit: 100000}).catch(() => ({entries: []}));
162
- // Export only this survey's tagged responses, not stray form submissions.
163
- const tag = `survey:${survey.data.slug}`;
164
- const rows = entries.filter(e => e.meta?.source === tag).map(e => e.data);
165
- if ((req.query.format || 'csv') === 'json') {
166
- return reply.header('Content-Disposition', `attachment; filename="${survey.data.slug}.json"`).send(rows);
167
- }
168
- return reply.header('Content-Type', 'text/csv')
169
- .header('Content-Disposition', `attachment; filename="${survey.data.slug}.csv"`)
170
- .send(toCsv(rows, cols));
171
- });
172
-
173
- fastify.surveys = {campaigns, audience, ledger, col, config};
174
- }