domma-cms 0.92.1 → 0.94.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 (177) hide show
  1. package/CLAUDE.md +5 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/action-editor-arrange.js +1 -1
  5. package/admin/js/lib/api-tokens-arrange.js +2 -2
  6. package/admin/js/lib/block-editor-arrange.js +1 -1
  7. package/admin/js/lib/blocks-arrange.js +1 -1
  8. package/admin/js/lib/collection-entries-arrange.js +1 -1
  9. package/admin/js/lib/components-arrange.js +1 -1
  10. package/admin/js/lib/dashboard-arrange.js +1 -1
  11. package/admin/js/lib/dates.js +1 -0
  12. package/admin/js/lib/forms-arrange.js +1 -1
  13. package/admin/js/lib/media-arrange.js +1 -1
  14. package/admin/js/lib/notifications-arrange.js +1 -1
  15. package/admin/js/lib/pages-arrange.js +1 -1
  16. package/admin/js/lib/related.js +1 -1
  17. package/admin/js/lib/timeline-builder.js +2 -2
  18. package/admin/js/templates/action-editor.html +6 -5
  19. package/admin/js/templates/actions-list.html +1 -1
  20. package/admin/js/templates/contacts.html +1 -1
  21. package/admin/js/templates/docs/api-actions.html +86 -60
  22. package/admin/js/templates/docs/api-authentication.html +159 -123
  23. package/admin/js/templates/docs/api-builder.html +197 -0
  24. package/admin/js/templates/docs/api-collections.html +199 -259
  25. package/admin/js/templates/docs/api-external.html +225 -0
  26. package/admin/js/templates/docs/api-forms.html +268 -0
  27. package/admin/js/templates/docs/api-layouts.html +70 -45
  28. package/admin/js/templates/docs/api-media.html +57 -80
  29. package/admin/js/templates/docs/api-navigation.html +66 -22
  30. package/admin/js/templates/docs/api-pages.html +109 -129
  31. package/admin/js/templates/docs/api-plugins.html +123 -61
  32. package/admin/js/templates/docs/api-scaffold.html +185 -0
  33. package/admin/js/templates/docs/api-settings.html +72 -64
  34. package/admin/js/templates/docs/api-users.html +74 -107
  35. package/admin/js/templates/docs/api-views.html +68 -54
  36. package/admin/js/templates/docs/components-howto.html +20 -17
  37. package/admin/js/templates/docs/components-reference.html +13 -16
  38. package/admin/js/templates/docs/components-rules.html +7 -6
  39. package/admin/js/templates/docs/components-walkthrough.html +19 -19
  40. package/admin/js/templates/docs/tutorial-crud.html +71 -40
  41. package/admin/js/templates/docs/tutorial-forms.html +51 -35
  42. package/admin/js/templates/docs/tutorial-plugin.html +132 -56
  43. package/admin/js/templates/docs/usage-actions.html +61 -15
  44. package/admin/js/templates/docs/usage-collections.html +108 -0
  45. package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
  46. package/admin/js/templates/docs/usage-dconfig.html +0 -3
  47. package/admin/js/templates/docs/usage-editions.html +213 -0
  48. package/admin/js/templates/docs/usage-media.html +22 -6
  49. package/admin/js/templates/docs/usage-navigation.html +74 -18
  50. package/admin/js/templates/docs/usage-pages.html +60 -20
  51. package/admin/js/templates/docs/usage-plugins.html +89 -17
  52. package/admin/js/templates/docs/usage-shortcodes.html +123 -70
  53. package/admin/js/templates/docs/usage-site-settings.html +50 -18
  54. package/admin/js/templates/docs/usage-tools.html +73 -0
  55. package/admin/js/templates/docs/usage-users-roles.html +99 -20
  56. package/admin/js/templates/docs/usage-views.html +36 -19
  57. package/admin/js/templates/documentation.html +153 -32
  58. package/admin/js/templates/page-editor.html +0 -5
  59. package/admin/js/templates/plugin-guide.html +15 -0
  60. package/admin/js/templates/plugin-guides.html +21 -0
  61. package/admin/js/templates/pro-docs.html +53 -234
  62. package/admin/js/templates/tutorials.html +5 -4
  63. package/admin/js/views/actions-list.js +3 -3
  64. package/admin/js/views/analytics.js +5 -5
  65. package/admin/js/views/api-endpoint-editor.js +2 -2
  66. package/admin/js/views/block-editor.js +4 -4
  67. package/admin/js/views/blocks.js +4 -4
  68. package/admin/js/views/collection-editor.js +4 -4
  69. package/admin/js/views/collection-entries.js +7 -7
  70. package/admin/js/views/component-editor.js +2 -2
  71. package/admin/js/views/contacts.js +22 -20
  72. package/admin/js/views/context-menu-editor.js +5 -5
  73. package/admin/js/views/doc-pages.js +1 -1
  74. package/admin/js/views/form-editor.js +4 -4
  75. package/admin/js/views/form-submissions.js +2 -2
  76. package/admin/js/views/index.js +1 -1
  77. package/admin/js/views/media.js +3 -3
  78. package/admin/js/views/menu-editor.js +13 -13
  79. package/admin/js/views/menu-locations.js +2 -2
  80. package/admin/js/views/my-profile.js +1 -1
  81. package/admin/js/views/page-editor.js +8 -8
  82. package/admin/js/views/plugin-guides.js +5 -0
  83. package/admin/js/views/project-detail.js +2 -2
  84. package/admin/js/views/project-settings.js +1 -1
  85. package/admin/js/views/role-editor.js +4 -4
  86. package/admin/js/views/search.js +2 -2
  87. package/admin/js/views/seo.js +17 -17
  88. package/admin/js/views/settings.js +3 -3
  89. package/admin/js/views/theme.js +3 -3
  90. package/admin/js/views/user-editor.js +1 -1
  91. package/admin/js/views/users.js +2 -2
  92. package/admin/js/views/view-editor.js +1 -1
  93. package/bin/cli.js +13 -13
  94. package/bin/lib/node-version.js +29 -0
  95. package/package.json +1 -1
  96. package/plugins/_lib/admin/mail/compose-window.js +3 -2
  97. package/plugins/_lib/admin/mail/reader-view.js +7 -6
  98. package/plugins/_lib/admin/mail/scheduling.js +4 -2
  99. package/plugins/_lib/admin/mail/templates.js +4 -4
  100. package/plugins/_lib/admin/ui/dates.js +85 -0
  101. package/plugins/blog/CLAUDE.md +31 -22
  102. package/plugins/blog/admin/views/blog.js +3 -2
  103. package/plugins/blog/admin/views/comments.js +2 -1
  104. package/plugins/blog/admin/views/post-editor.js +4 -4
  105. package/plugins/blog/blocks/blog-card-row.html +1 -1
  106. package/plugins/blog/blocks/blog-card.html +2 -2
  107. package/plugins/blog/blocks/blog-post-classic.html +2 -2
  108. package/plugins/blog/blocks/blog-post-essay.html +2 -2
  109. package/plugins/blog/blocks/blog-post-feature.html +2 -2
  110. package/plugins/blog/blocks/blog-post-minimal.html +2 -2
  111. package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
  112. package/plugins/blog/blocks/blog-post-split.html +2 -2
  113. package/plugins/blog/docs/guide.md +205 -0
  114. package/plugins/blog/lib/layouts.js +3 -3
  115. package/plugins/blog/lib/page.js +2 -1
  116. package/plugins/blog/plugin.js +3 -3
  117. package/plugins/blog/plugin.json +4 -4
  118. package/plugins/blog/tests/layouts.test.js +6 -0
  119. package/plugins/feedback/CLAUDE.md +22 -3
  120. package/plugins/feedback/admin/lib/kit.js +6 -7
  121. package/plugins/feedback/admin/views/feedback.js +79 -10
  122. package/plugins/feedback/admin/views/send.js +28 -6
  123. package/plugins/feedback/docs/guide.md +95 -0
  124. package/plugins/feedback/lib/receiver.js +9 -2
  125. package/plugins/feedback/lib/sender.js +3 -2
  126. package/plugins/feedback/plugin.js +54 -6
  127. package/plugins/feedback/plugin.json +4 -4
  128. package/plugins/feedback/tests/api.test.js +74 -2
  129. package/plugins/free-tier.lock.json +49 -44
  130. package/plugins/mail-reader/CLAUDE.md +33 -18
  131. package/plugins/mail-reader/docs/guide.md +147 -0
  132. package/plugins/mail-reader/plugin.json +1 -1
  133. package/plugins/security/CLAUDE.md +4 -1
  134. package/plugins/security/admin/views/security.js +5 -5
  135. package/plugins/security/docs/guide.md +170 -0
  136. package/plugins/security/plugin.js +2 -1
  137. package/plugins/security/plugin.json +2 -1
  138. package/plugins/shopping-cart/CLAUDE.md +7 -1
  139. package/plugins/shopping-cart/admin/lib/kit.js +5 -2
  140. package/plugins/shopping-cart/admin/views/orders.js +4 -4
  141. package/plugins/shopping-cart/admin/views/overview.js +2 -2
  142. package/plugins/shopping-cart/docs/guide.md +191 -0
  143. package/plugins/shopping-cart/lib/render.js +2 -1
  144. package/plugins/shopping-cart/plugin.json +3 -3
  145. package/public/js/collection-browser.js +2 -2
  146. package/public/js/site.js +1 -1
  147. package/scripts/gen-instance-secret.js +3 -1
  148. package/scripts/setup.js +3 -1
  149. package/server/middleware/auth.js +2 -1
  150. package/server/routes/api/actions.js +47 -27
  151. package/server/routes/api/blocks.js +2 -1
  152. package/server/routes/api/collections.js +16 -52
  153. package/server/routes/api/contacts.js +66 -3
  154. package/server/routes/api/documentation.js +42 -0
  155. package/server/routes/api/notifications.js +3 -2
  156. package/server/routes/api/users.js +10 -6
  157. package/server/server.js +16 -1
  158. package/server/services/actions.js +110 -34
  159. package/server/services/adapterRegistry.js +169 -16
  160. package/server/services/adapters/FileAdapter.js +25 -0
  161. package/server/services/adapters/MongoAdapter.js +23 -0
  162. package/server/services/collections.js +104 -1
  163. package/server/services/connectionManager.js +12 -0
  164. package/server/services/dates.js +81 -0
  165. package/server/services/docs.js +13 -2
  166. package/server/services/markdown.js +75 -26
  167. package/server/services/notification-sources.js +6 -5
  168. package/server/services/passwordReset.js +2 -1
  169. package/server/services/permissionRegistry.js +3 -2
  170. package/server/services/pluginGuides.js +255 -0
  171. package/server/services/pluginInstaller.js +54 -13
  172. package/server/services/plugins.js +29 -1
  173. package/server/services/presetCollections.js +31 -5
  174. package/server/services/renderer.js +2 -2
  175. package/server/services/sidebarBadges.js +3 -1
  176. package/server/services/tools.js +4 -2
  177. package/server/templates/page.html +2 -2
@@ -2,18 +2,25 @@
2
2
  * Feedback - one sidebar entry, two screens, decided by the server:
3
3
  *
4
4
  * on a site Send feedback, and what this site has sent (send.js)
5
- * on the manager the inbox of every site's feedback (inbox.js)
5
+ * on the manager the inbox of every site's feedback (inbox.js); the level-0 role
6
+ * also gets a "My feedback" tab - send.js again, filing straight
7
+ * into that inbox as "Manager (this server)"
6
8
  *
7
9
  * #/plugins/feedback the screen
8
10
  * #/plugins/feedback/<id> the same, with that report open (a notification's link)
11
+ * #/plugins/feedback/mine the manager, on the My feedback tab
9
12
  *
10
13
  * Path segments, not ?query: the admin router 404s a query on a fresh load.
11
14
  */
12
15
 
13
- import {api, loadKit} from '../lib/kit.js';
16
+ import {api, loadKit, store} from '../lib/kit.js';
14
17
 
15
18
  const ROUTE = /^#\/plugins\/feedback(\/|$)/;
16
- const V = '1.0.0';
19
+ const V = '1.1.0';
20
+ const TABS = [
21
+ {key: 'inbox', label: 'Inbox', icon: 'inbox'},
22
+ {key: 'mine', label: 'My feedback', icon: 'send'}
23
+ ];
17
24
  let lifetime = null;
18
25
 
19
26
  export const feedbackView = {
@@ -50,14 +57,76 @@ export const feedbackView = {
50
57
  }
51
58
  if (!here()) return;
52
59
 
53
- const screen = mode.mode === 'receiver' ? 'inbox' : 'send';
54
- try {
60
+ const load = async (screen, into, ctx) => {
55
61
  const mod = await import(`/plugins/feedback/admin/views/${screen}.js?v=${V}`);
56
- if (!here()) return;
57
- const cleanup = await mod.mount(root, {kit, scope, here, container, $container, mode, meta, id});
58
- if (typeof cleanup === 'function') scope(cleanup);
59
- } catch (err) {
60
- fail(`This screen could not load: ${err.message || err}`);
62
+ if (!here()) return null;
63
+ return mod.mount(into, {kit, container, $container, meta, here, ...ctx});
64
+ };
65
+
66
+ // A site, or a manager user below level 0: one screen, as before.
67
+ if (mode.mode !== 'receiver' || !mode.canSend) {
68
+ try {
69
+ const cleanup = await load(mode.mode === 'receiver' ? 'inbox' : 'send', root, {scope, mode, id});
70
+ if (typeof cleanup === 'function') scope(cleanup);
71
+ } catch (err) {
72
+ fail(`This screen could not load: ${err.message || err}`);
73
+ }
74
+ return;
61
75
  }
76
+
77
+ // The manager's level-0 role: the inbox, and its own feedback. One tab mounted at a time,
78
+ // each with its own cleanups, so switching back always shows fresh data.
79
+ root.innerHTML = `
80
+ <div class="fb-bar fb-tabs">
81
+ <div class="fb-seg" role="tablist" aria-label="Feedback">
82
+ ${TABS.map(t => `<button type="button" role="tab" data-tab="${t.key}"><span data-icon="${t.icon}" data-icon-size="14"></span> ${t.label}</button>`).join('')}
83
+ </div>
84
+ <span data-help-title="Your own feedback" data-help="Inbox is every report, from every site and from this server. My feedback is where you file your own - a bug, a request or a note to self about the CMS or the Manager. It goes straight into the Inbox, marked Manager (this server), and then works like any site's report: status, reply and a Waypoint issue. Only the most senior role sees this tab."></span>
85
+ </div>
86
+ <div data-feedback-pane></div>`;
87
+ scope(kit.helpPopovers(root, {position: 'bottom'}));
88
+ I.scan(root);
89
+ const pane = root.querySelector('[data-feedback-pane]');
90
+ const tabs = root.querySelector('.fb-seg');
91
+ let paneCleanups = [];
92
+ const disposePane = () => { for (const fn of paneCleanups.splice(0)) { try { fn(); } catch { /* already gone */ } } };
93
+ scope(disposePane);
94
+
95
+ let current = '';
96
+ const show = async (key, openId = '') => {
97
+ if (key === current) return;
98
+ current = key;
99
+ store.set('feedback.tab', key);
100
+ for (const b of tabs.querySelectorAll('[data-tab]')) {
101
+ const on = b.dataset.tab === key;
102
+ b.classList.toggle('is-on', on);
103
+ b.setAttribute('aria-selected', String(on));
104
+ }
105
+ disposePane();
106
+ pane.innerHTML = '';
107
+ const mine = () => current === key && here();
108
+ const paneScope = (fn) => { paneCleanups.push(fn); return fn; };
109
+ try {
110
+ const cleanup = await load(key === 'mine' ? 'send' : 'inbox', pane, {
111
+ scope: paneScope, here: mine, id: openId,
112
+ mode: key === 'mine' ? {mode: 'sender', connected: true, local: true} : mode
113
+ });
114
+ if (typeof cleanup === 'function') paneScope(cleanup);
115
+ } catch (err) {
116
+ pane.innerHTML = '';
117
+ const p = document.createElement('p');
118
+ p.className = 'fb-note is-bad';
119
+ p.textContent = `This screen could not load: ${err.message || err}`;
120
+ pane.appendChild(p);
121
+ }
122
+ };
123
+ const onTab = (e) => { const b = e.target.closest('[data-tab]'); if (b) show(b.dataset.tab); };
124
+ tabs.addEventListener('click', onTab);
125
+ scope(() => tabs.removeEventListener('click', onTab));
126
+
127
+ // A report id in the path opens the inbox on it; /mine, or the last tab used, otherwise.
128
+ if (id === 'mine') await show('mine');
129
+ else if (id) await show('inbox', id);
130
+ else await show(store.get('feedback.tab') === 'mine' ? 'mine' : 'inbox');
62
131
  }
63
132
  };
@@ -5,6 +5,9 @@
5
5
  * A report is kept on this site before it is sent, so a Domma server that is
6
6
  * restarting only delays it ("Waiting to send"); opening this screen tries
7
7
  * again. A site the manager did not start has nowhere to send to, and says so.
8
+ *
9
+ * On the manager itself (ctx.mode.local - the level-0 role's "My feedback" tab)
10
+ * the same screen files straight into the inbox: nothing waits, nothing travels.
8
11
  */
9
12
 
10
13
  import {api, esc, openPanel, refreshBadge, stamp, when} from '../lib/kit.js';
@@ -22,6 +25,24 @@ const TONE = {new: 'info', acknowledged: 'muted', planned: 'info', 'in-progress'
22
25
 
23
26
  export async function mount(root, ctx) {
24
27
  const {kit, scope, here, meta, id} = ctx;
28
+ const local = Boolean(ctx.mode.local);
29
+ const COPY = local ? {
30
+ lead: 'File your own feedback on the CMS or the Manager.',
31
+ sub: 'It goes straight into the Inbox as Manager (this server) - status, reply and Waypoint work as for any site.',
32
+ help: 'For you and the other holders of the most senior role on this server. A report lands in the Inbox tab at once, marked Manager (this server), so it sits beside the sites\' reports and can be answered or made a Waypoint issue there. This list is everything this server has filed, whoever filed it, with its status and any reply.',
33
+ title: 'Send feedback',
34
+ hint: 'Filed with your name and this server\'s CMS version.',
35
+ replied: 'Reply',
36
+ noReply: 'No reply yet. Answer it, or file it in Waypoint, from the Inbox tab.'
37
+ } : {
38
+ lead: 'Tell Domma what is broken, missing or good.',
39
+ sub: 'Every report is read. You will see its progress here, and any reply.',
40
+ help: 'Reports go to the Domma team, with your name and this site attached so we can come back to you. You see everything this site has sent, whoever sent it. A report shows as Seen, Planned, In progress, Done or Not planned as it moves, and a reply from Domma appears in it - the sidebar badge counts replies you have not opened.',
41
+ title: 'Send feedback to Domma',
42
+ hint: 'Sent with your name, this site and its CMS version, so Domma can reply.',
43
+ replied: 'Domma replied',
44
+ noReply: 'No reply yet. The status above moves as Domma works on it.'
45
+ };
25
46
  const types = meta.types || [];
26
47
  const iconOf = (t) => types.find(x => x.key === t)?.icon || 'message-square';
27
48
  const labelOf = (t) => types.find(x => x.key === t)?.short || t;
@@ -58,10 +79,10 @@ export async function mount(root, ctx) {
58
79
  <div data-bind-hidden="!connected.value">
59
80
  <div class="fb-bar">
60
81
  <div class="fb-intro">
61
- <strong>Tell Domma what is broken, missing or good.</strong>
62
- <span>Every report is read. You will see its progress here, and any reply.</span>
82
+ <strong>${esc(COPY.lead)}</strong>
83
+ <span>${esc(COPY.sub)}</span>
63
84
  </div>
64
- <span data-help-title="Feedback" data-help="Reports go to the Domma team, with your name and this site attached so we can come back to you. You see everything this site has sent, whoever sent it. A report shows as Seen, Planned, In progress, Done or Not planned as it moves, and a reply from Domma appears in it - the sidebar badge counts replies you have not opened."></span>
85
+ <span data-help-title="Feedback" data-help="${esc(COPY.help)}"></span>
65
86
  <span class="fb-spacer"></span>
66
87
  <button type="button" class="btn btn-primary btn-sm" data-on-click="compose"><span data-icon="send" data-icon-size="14"></span> Send feedback</button>
67
88
  </div>
@@ -116,7 +137,7 @@ export async function mount(root, ctx) {
116
137
  const isBug = M.computed(() => f.type.value === 'bug');
117
138
  let panel = null;
118
139
  openPanel({
119
- title: 'Send feedback to Domma',
140
+ title: COPY.title,
120
141
  size: 'md',
121
142
  model: {...f, isBug},
122
143
  html: `<div class="fb-form">
@@ -136,7 +157,7 @@ export async function mount(root, ctx) {
136
157
  </div>
137
158
  <label class="form-label" for="fb-where">Where <span data-help="The screen or page it is about, if there is one - e.g. Pages, the Blog editor, /contact. Optional."></span></label>
138
159
  <input id="fb-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
139
- <p class="fb-hint">Sent with your name, this site and its CMS version, so Domma can reply.</p>
160
+ <p class="fb-hint">${esc(COPY.hint)}</p>
140
161
  <div class="fb-actions"><span class="fb-spacer"></span>
141
162
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
142
163
  <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
@@ -152,6 +173,7 @@ export async function mount(root, ctx) {
152
173
  where: f.where.peek(), severity: f.type.peek() === 'bug' ? f.severity.peek() : ''});
153
174
  E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
154
175
  panel?.close();
176
+ refreshBadge();
155
177
  await load();
156
178
  } catch (err) {
157
179
  E.toast(err.message || String(err), {type: 'error'});
@@ -189,7 +211,7 @@ export async function mount(root, ctx) {
189
211
  <dl class="fb-facts">${lines.map(([a, b]) => `<div><dt>${esc(a)}</dt><dd>${esc(b)}</dd></div>`).join('')}</dl>
190
212
  <h4>What was sent</h4>
191
213
  <div class="fb-text">${esc(r.description)}</div>
192
- ${r.reply ? `<h4><span data-icon="corner-up-left" data-icon-size="14"></span> Domma replied <small>${esc(stamp(r.replyAt))}</small></h4><div class="fb-text fb-reply">${esc(r.reply)}</div>` : '<p class="fb-note">No reply yet. The status above moves as Domma works on it.</p>'}
214
+ ${r.reply ? `<h4><span data-icon="corner-up-left" data-icon-size="14"></span> ${esc(COPY.replied)} <small>${esc(stamp(r.replyAt))}</small></h4><div class="fb-text fb-reply">${esc(r.reply)}</div>` : `<p class="fb-note">${esc(COPY.noReply)}</p>`}
193
215
  </div>`
194
216
  });
195
217
  if (r.unread && r.id) {
@@ -0,0 +1,95 @@
1
+ ---
2
+ title: Guide
3
+ order: 1
4
+ ---
5
+
6
+ Feedback lets you tell Domma what is broken, missing or good, straight from your admin, and follow what happens to it. It is free.
7
+
8
+ ## What it does
9
+
10
+ - Sends a report to the Domma team with your name, your site and its CMS version attached, so Domma can reply.
11
+ - Lists every report this site has sent, whoever sent it, with its status.
12
+ - Shows Domma's reply inside the report, and tells you when one arrives.
13
+ - Keeps a report safe if Domma cannot be reached, and sends it as soon as it can.
14
+
15
+ ## Getting started
16
+
17
+ 1. Open [Feedback](#/plugins/feedback) from the admin sidebar.
18
+ 2. Click **Send feedback**.
19
+ 3. Under **What is it?** pick one: Something is broken, An idea or request, A question, Something we got right, or Something else.
20
+ 4. Type a **Title** (one line) and a **Description**.
21
+ 5. For something broken, choose **How bad is it?**: Minor - a nuisance, Major - gets in the way, or Blocker - cannot carry on.
22
+ 6. Optional: fill in **Where** - the screen or page it is about, for example "Pages" or `/contact`.
23
+ 7. Click **Send**.
24
+
25
+ A good bug report says what you did, what you expected and what happened instead. For a request, say what you are trying to get done.
26
+
27
+ ## Screens
28
+
29
+ ### Your reports
30
+
31
+ Each row shows the type, title, when it was sent and by whom, and a status:
32
+
33
+ | Status | Meaning |
34
+ |---|---|
35
+ | New | Domma has it and has not looked yet |
36
+ | Seen | Domma has read it |
37
+ | Planned | It is on the list |
38
+ | In progress | Being worked on |
39
+ | Done | Finished |
40
+ | Not planned | Domma does not intend to do it |
41
+ | Waiting to send | Domma could not be reached yet; it goes automatically |
42
+ | Not accepted | Domma's server turned the report down |
43
+
44
+ A dot on a row means a new reply. Click a row to open it: you see the details, **What was sent**, and **Domma replied** with the reply, or "No reply yet".
45
+
46
+ If "Tracked as" appears, Domma has filed the report in its own issue tracker, and the status follows that issue.
47
+
48
+ When reports are waiting to send, a note says so, with **Try now**.
49
+
50
+ ### Not connected
51
+
52
+ If the screen says "This site is not connected to Domma", your site was not started by a Domma server, so there is nowhere to send to. Ask whoever set the site up.
53
+
54
+ ## Settings
55
+
56
+ There is no settings screen. Whoever runs the site can change one option in the plugin's configuration file:
57
+
58
+ | Option | Default | Meaning |
59
+ |---|---|---|
60
+ | `syncMinutes` | 60 | How often the site sends waiting reports and checks for replies |
61
+
62
+ Opening the Feedback screen also sends anything waiting and checks for replies straight away.
63
+
64
+ ## Notifications
65
+
66
+ When Domma replies, you get an admin notification "Domma replied:" followed by the report's title. The sidebar badge counts replies you have not opened; opening the report clears it.
67
+
68
+ ## Permissions and roles
69
+
70
+ In System > Roles, Feedback appears in the **Plugins** group:
71
+
72
+ | Action | Label | Allows |
73
+ |---|---|---|
74
+ | `feedback.send` | Send | Send feedback to Domma and see what this site has sent |
75
+ | `feedback.manage` | Manage | Only used on the Domma server (see below) |
76
+
77
+ Only the **admin** role has them by default. Everyone with **Send** sees every report the site has sent, not just their own.
78
+
79
+ ## On the Domma server
80
+
81
+ On the Domma server itself (the Manager), the same screen is the **Inbox** of every site's reports, for holders of **Feedback > Manage**:
82
+
83
+ - Filter by **Open**, each status, or **All**; by site and type; and search title, text, person and site.
84
+ - Open a report to set its **Status**, write a **Reply to the site** (the site sees it and is notified) and keep **Internal notes** (never sent to the site). Click **Save**.
85
+ - With Waypoint Pro installed, **Make a Waypoint issue** files the report in a project with a type and priority. The report then follows the issue: planned, in progress, done. "Not planned" set by hand stays until you change it.
86
+ - Right-click a row to open it, change its status quickly, or **Delete** it. Deleting removes it for the site too.
87
+ - Holders of the most senior role also get **My feedback**, to file their own reports. These appear in the Inbox as "Manager (this server)".
88
+
89
+ ## Limitations
90
+
91
+ - Only sites started by a Domma server can send. A standalone install cannot.
92
+ - No attachments or screenshots. Describe the problem in words, and use **Where** for the page.
93
+ - Titles are limited to 140 characters and descriptions to 8,000.
94
+ - The Domma server accepts up to 10 reports from one site in 10 minutes. Any more show as **Waiting to send** and go later.
95
+ - You cannot edit or withdraw a report once sent. Send a follow-up instead.
@@ -21,6 +21,10 @@ import {receivedReport, statusFromIssue, statusLabel} from './shape.js';
21
21
 
22
22
  export const COLLECTION = 'feedback-reports';
23
23
 
24
+ /** The site slug the manager's OWN reports carry. Site slugs start [a-z0-9], so no site can be this. */
25
+ export const MANAGER_SITE = '_manager';
26
+ export const MANAGER_NAME = 'Manager (this server)';
27
+
24
28
  const HERE = path.dirname(fileURLToPath(import.meta.url));
25
29
  const SITE_MANAGER = path.resolve(HERE, '..', '..', 'site-manager');
26
30
  const TOKENS = path.join(SITE_MANAGER, 'services', 'fleetTokens.js');
@@ -68,9 +72,12 @@ export const removeReport = (id) => deleteEntry(COLLECTION, id);
68
72
  * A site's report arrived. The same site sending the same `ref` again (a retry
69
73
  * after a reply that never reached it) gets the report it already made.
70
74
  *
75
+ * @param {string} slug
76
+ * @param {object} payload
77
+ * @param {string} [source] - entry meta source: 'fleet' from a site, 'manager' for its own
71
78
  * @returns {Promise<{entry?: object, created?: boolean, error?: string}>}
72
79
  */
73
- export async function receive(slug, payload) {
80
+ export async function receive(slug, payload, source = 'fleet') {
74
81
  const {data, error} = receivedReport(payload, slug);
75
82
  if (error) return {error};
76
83
  if (data.ref) {
@@ -78,7 +85,7 @@ export async function receive(slug, payload) {
78
85
  if (same) return {entry: same, created: false};
79
86
  }
80
87
  data.history = [{at: new Date().toISOString(), by: data.userName, what: 'Sent'}];
81
- const entry = await createEntry(COLLECTION, data, {source: 'fleet'});
88
+ const entry = await createEntry(COLLECTION, data, {source});
82
89
  return {entry, created: true};
83
90
  }
84
91
 
@@ -31,10 +31,11 @@ export function managerOf(env = process.env) {
31
31
  * The outbox: this site's copy of everything it sent, in data/outbox.json.
32
32
  *
33
33
  * @param {string} dataDir
34
+ * @param {string} [name] - the manager keeps only `seen` for its own reports, in own.json
34
35
  * @returns {object}
35
36
  */
36
- export function createOutbox(dataDir) {
37
- const file = path.join(dataDir, 'outbox.json');
37
+ export function createOutbox(dataDir, name = 'outbox.json') {
38
+ const file = path.join(dataDir, name);
38
39
  const read = () => {
39
40
  try {
40
41
  const j = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -15,6 +15,9 @@
15
15
  * POST /fleet/submit a site's report (Bearer fleet token)
16
16
  * GET /fleet/mine that site's reports, sender-safe fields only
17
17
  * GET /mode · GET /count 'new' reports
18
+ * POST /send · GET /sent · PUT /sent/:id/seen
19
+ * the manager's OWN feedback (level-0 role only):
20
+ * written straight into the inbox as site `_manager`
18
21
  * GET /reports · GET /reports/:id · PUT /reports/:id · DELETE /reports/:id
19
22
  * GET /waypoint is Waypoint Pro here, and its projects
20
23
  * POST /reports/:id/promote make a Waypoint issue from a report
@@ -30,6 +33,7 @@ import {
30
33
  SEVERITIES, STATUSES, TYPES, buildPayload, cleanSubmission, cleanUpdate, createThrottle, issueDescription, senderView
31
34
  } from './lib/shape.js';
32
35
  import {createOutbox, flush, managerApi, managerOf, mergeReports, newRef} from './lib/sender.js';
36
+ import {getEffectiveLevel} from '../../server/services/userRoles.js';
33
37
 
34
38
  const HERE = path.dirname(fileURLToPath(import.meta.url));
35
39
  const NAME = 'feedback';
@@ -67,7 +71,7 @@ export default async function feedbackPlugin(fastify, options) {
67
71
 
68
72
  fastify.get('/meta', can('send'), async () => ({types: TYPES, statuses: STATUSES, severities: SEVERITIES}));
69
73
 
70
- if (mode === 'receiver') await receiverRoutes(fastify, {can, hooks, notifyOk, receiverMod, settings, options});
74
+ if (mode === 'receiver') await receiverRoutes(fastify, {can, authenticate, requirePermission, hooks, notifyOk, receiverMod, settings, options});
71
75
  else await senderRoutes(fastify, {can, hooks, notifyOk, settings, options});
72
76
  }
73
77
 
@@ -152,10 +156,21 @@ async function senderRoutes(fastify, {can, hooks, notifyOk, settings, options})
152
156
  // Receiver (the manager)
153
157
  // ---------------------------------------------------------------------------------
154
158
 
155
- async function receiverRoutes(fastify, {can, hooks, notifyOk, receiverMod, settings, options}) {
159
+ /** Level 0 - the most senior role, whatever it is called. Roles are data, never names in code. */
160
+ const isTopLevel = (user) => { try { return getEffectiveLevel(user) === 0; } catch { return false; } };
161
+
162
+ async function receiverRoutes(fastify, {can, authenticate, requirePermission, hooks, notifyOk, receiverMod, settings, options}) {
156
163
  const rx = receiverMod;
157
164
  const slugOf = options.slugFromToken || rx.slugFromToken;
158
- const names = options.siteNames || rx.siteNames;
165
+ const siteNames = options.siteNames || rx.siteNames;
166
+ // The manager's own reports are filed as `_manager` - a slug no site can have (theirs start [a-z0-9]).
167
+ const names = () => ({...siteNames(), [rx.MANAGER_SITE]: rx.MANAGER_NAME});
168
+ // Its own feedback: the level-0 role only, on top of the `feedback` send permission.
169
+ const topOnly = async (request, reply) => {
170
+ if (!isTopLevel(request.user)) return reply.code(403).send({error: 'Only the most senior role on this server can send feedback from here.'});
171
+ };
172
+ const own = {preHandler: [authenticate, requirePermission('feedback', 'send'), topOnly]};
173
+ const ownSeen = createOutbox(options.dataDir || path.join(HERE, 'data'), 'own.json');
159
174
  const allowed = createThrottle({max: Number(settings.perSiteMax) || 10, windowMs: (Number(settings.perSiteMinutes) || 10) * 60_000});
160
175
  try { await rx.ensureCollection(); } catch (err) { fastify.log.error(`[feedback] collection: ${err.message}`); }
161
176
 
@@ -195,20 +210,49 @@ async function receiverRoutes(fastify, {can, hooks, notifyOk, receiverMod, setti
195
210
 
196
211
  // ---- The inbox ----
197
212
 
198
- fastify.get('/mode', can('manage'), async () => ({mode: 'receiver', waypoint: Boolean(waypoint())}));
213
+ fastify.get('/mode', can('manage'), async (request) => ({mode: 'receiver', waypoint: Boolean(waypoint()), canSend: isTopLevel(request.user)}));
199
214
 
200
- fastify.get('/count', can('manage'), async () => {
201
- const fresh = (await rx.allReports()).filter(e => e.data?.status === 'new');
215
+ fastify.get('/count', can('manage'), async (request) => {
216
+ const all = await rx.allReports();
217
+ const fresh = all.filter(e => e.data?.status === 'new');
202
218
  const n = names();
219
+ const seen = ownSeen.seen();
220
+ const replies = isTopLevel(request.user)
221
+ ? all.filter(e => e.data?.site === rx.MANAGER_SITE && e.data.reply && e.data.replyAt && seen[e.id] !== e.data.replyAt).length : 0;
203
222
  return {
204
223
  count: fresh.length,
205
224
  label: fresh.length ? `${fresh.length} new` : '',
206
225
  tone: fresh.some(e => e.data?.severity === 'blocker') ? 'danger' : 'info',
207
226
  details: {title: 'New feedback', items: fresh.slice(0, 6).map(e => ({text: e.data.title, meta: n[e.data.site] || e.data.site})),
227
+ ...(replies && {rows: [{label: 'New', value: String(fresh.length)}, {label: 'Replies to your own feedback', value: String(replies)}]}),
208
228
  empty: 'Nothing new.'}
209
229
  };
210
230
  });
211
231
 
232
+ // ---- The manager's own feedback: straight into the inbox, no HTTP round trip ----
233
+
234
+ fastify.post('/send', own, async (request, reply) => {
235
+ const {report, error} = cleanSubmission(request.body || {});
236
+ if (error) return reply.code(400).send({error});
237
+ if (!allowed(rx.MANAGER_SITE)) return reply.code(429).send({error: 'That is a lot of feedback at once - try again in a few minutes.'});
238
+ const payload = buildPayload({report, user: request.user, cmsVersion: cmsVersion(), site: rx.MANAGER_SITE, ref: newRef()});
239
+ const r = await rx.receive(rx.MANAGER_SITE, payload, 'manager');
240
+ if (r.error) return reply.code(400).send({error: r.error});
241
+ return reply.code(201).send({id: r.entry.id, ref: payload.ref, state: 'sent', message: 'Filed in the inbox.'});
242
+ });
243
+
244
+ fastify.get('/sent', own, async () => {
245
+ const seen = ownSeen.seen();
246
+ const reports = (await rx.reportsOf(rx.MANAGER_SITE)).map(senderView)
247
+ .map(r => ({...r, local: false, unread: Boolean(r.reply && r.replyAt && seen[r.id] !== r.replyAt)}));
248
+ return {connected: true, reachable: true, error: '', local: true, reports};
249
+ });
250
+
251
+ fastify.put('/sent/:id/seen', own, async (request) => {
252
+ ownSeen.markSeen(String(request.params.id), String(request.body?.replyAt || ''));
253
+ return {ok: true};
254
+ });
255
+
212
256
  fastify.get('/reports', can('manage'), async () => {
213
257
  const n = names();
214
258
  return {sites: n, reports: (await rx.allReports()).map(e => ({id: e.id, ...e.data, siteName: n[e.data.site] || e.data.site,
@@ -235,6 +279,10 @@ async function receiverRoutes(fastify, {can, hooks, notifyOk, receiverMod, setti
235
279
  const {patch, error} = cleanUpdate(request.body || {}, e.data);
236
280
  if (error) return reply.code(400).send({error});
237
281
  const next = await rx.applyPatch(e.id, patch, request.user?.name || 'Domma');
282
+ // Answering your own report is not news to you.
283
+ if (next.data.site === rx.MANAGER_SITE && next.data.replyAt && next.data.userId && next.data.userId === String(request.user?.id || '')) {
284
+ ownSeen.markSeen(next.id, next.data.replyAt);
285
+ }
238
286
  return {id: next.id, ...next.data, createdAt: next.meta?.createdAt, updatedAt: next.meta?.updatedAt};
239
287
  });
240
288
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feedback",
3
3
  "displayName": "Feedback",
4
- "version": "1.0.0",
4
+ "version": "1.1.2",
5
5
  "tier": "free",
6
6
  "description": "Tell Domma what is broken, missing or good - straight from your admin - and see what became of it: seen, planned, in progress, done, with Domma's reply.",
7
7
  "author": "Domma CMS",
@@ -13,9 +13,9 @@
13
13
  "label": "Feedback",
14
14
  "group": "Plugins",
15
15
  "icon": "message-square",
16
- "description": "Send feedback to Domma and read the replies (send). On the Domma server itself: read, answer and file everything the sites sent (manage).",
16
+ "description": "Send feedback to Domma and read the replies (send). On the Domma server itself: read, answer and file everything the sites sent (manage); the level-0 role can also file its own there.",
17
17
  "actions": [
18
- {"key": "send", "label": "Send", "description": "Send feedback to Domma and see what this site has sent"},
18
+ {"key": "send", "label": "Send", "description": "Send feedback to Domma and see what this site has sent (on the Domma server: the level-0 role only)"},
19
19
  {"key": "manage", "label": "Manage", "description": "On the Domma server: the inbox of every site's feedback"}
20
20
  ],
21
21
  "grant": ["admin"]
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "views": {
42
42
  "plugin-feedback": {
43
- "entry": "feedback/admin/views/feedback.js?v=1.0.0",
43
+ "entry": "feedback/admin/views/feedback.js?v=1.1.1",
44
44
  "exportName": "feedbackView"
45
45
  }
46
46
  }
@@ -11,6 +11,7 @@ import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import Fastify from 'fastify';
13
13
  import feedback from '../plugin.js';
14
+ import {getRoleHierarchy, getRoleLevel, load as loadRoles} from '../../../server/services/roles.js';
14
15
 
15
16
  const PLUGINS = path.resolve('config/plugins.json');
16
17
  const COLL = path.resolve('content/collections/feedback-reports');
@@ -21,6 +22,9 @@ let dataDir;
21
22
  let managerUp = true;
22
23
  const notices = [];
23
24
  const siteNotices = [];
25
+ // Roles are data: the level-0 role and one below it, whatever this checkout calls them.
26
+ let TOP;
27
+ let BELOW;
24
28
 
25
29
  /** A site's fetch: into the manager app, or a connection failure while it is "restarting". */
26
30
  const fetchInto = (app) => async (url, init = {}) => {
@@ -53,10 +57,15 @@ before(async () => {
53
57
  savedPlugins = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
54
58
  await fs.rm(COLL, {recursive: true, force: true});
55
59
  dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'feedback-test-'));
60
+ await loadRoles();
61
+ TOP = getRoleHierarchy().find(r => getRoleLevel(r) === 0);
62
+ BELOW = getRoleHierarchy().find(r => getRoleLevel(r) > 0);
56
63
  manager = Fastify();
57
64
  await manager.register(feedback, {
58
- auth: {authenticate: async (req) => { req.user = {id: 'v1', name: 'Vendor', role: 'super-admin'}; }, requirePermission: () => async () => {}},
59
- prefix: '/api/plugins/feedback',
65
+ // x-test-user swaps the signed-in user for one request; otherwise the vendor at level 0.
66
+ auth: {authenticate: async (req) => { req.user = req.headers['x-test-user'] ? JSON.parse(req.headers['x-test-user']) : {id: 'v1', name: 'Vendor', role: TOP}; },
67
+ requirePermission: () => async () => {}},
68
+ prefix: '/api/plugins/feedback', dataDir: await fs.mkdtemp(path.join(dataDir, 'manager-')),
60
69
  settings: {perSiteMax: 6, perSiteMinutes: 10}, mode: 'receiver',
61
70
  slugFromToken: async (h) => TOKENS[String(h || '').replace(/^Bearer\s+/i, '')] || null,
62
71
  siteNames: () => ({acme: 'Acme Ltd', beta: 'Beta Co'}),
@@ -217,3 +226,66 @@ test('a site the manager did not start cannot send, and says so', async () => {
217
226
  assert.equal((await call(app, 'POST', '/send', {title: 't', description: 'd'})).status, 409);
218
227
  await app.close();
219
228
  });
229
+
230
+ test('the manager\'s level-0 role files its own feedback straight into the inbox', async () => {
231
+ assert.ok(TOP && BELOW, 'this checkout has a level-0 role and one below it');
232
+ const P = '/api/plugins/feedback';
233
+ const me = {id: 'v1', name: 'Vendor', email: 'v@domma.test', role: TOP};
234
+ const as = (u) => ({'x-test-user': JSON.stringify(u)});
235
+ assert.equal((await call(manager, 'GET', `${P}/mode`, null, as(me))).body.canSend, true);
236
+
237
+ let r = await call(manager, 'POST', `${P}/send`, {type: 'bug', title: 'Manager hiccup', description: 'The fleet list flickers.', severity: 'minor',
238
+ user: {name: 'Mallory'}, site: 'acme'}, as(me));
239
+ assert.equal(r.status, 201, JSON.stringify(r.body));
240
+ assert.equal(r.body.state, 'sent');
241
+
242
+ const inbox = (await call(manager, 'GET', `${P}/reports`)).body;
243
+ const got = inbox.reports.find(x => x.title === 'Manager hiccup');
244
+ assert.equal(got.site, '_manager', 'marked as the manager, whatever the body says');
245
+ assert.equal(got.siteName, 'Manager (this server)');
246
+ assert.equal(got.userName, 'Vendor', 'the session names the sender');
247
+ assert.equal(got.status, 'new');
248
+ assert.equal(inbox.sites._manager, 'Manager (this server)', 'the site filter offers it');
249
+ assert.ok(!notices.some(n => n.title.includes('Manager hiccup')), 'no notice to yourself');
250
+
251
+ // Below level 0: refused, and not offered.
252
+ const admin = {id: 'a1', name: 'Ada', role: BELOW};
253
+ assert.equal((await call(manager, 'GET', `${P}/mode`, null, as(admin))).body.canSend, false);
254
+ assert.equal((await call(manager, 'POST', `${P}/send`, {title: 't', description: 'd'}, as(admin))).status, 403);
255
+ assert.equal((await call(manager, 'GET', `${P}/sent`, null, as(admin))).status, 403);
256
+ assert.equal((await call(manager, 'PUT', `${P}/sent/${got.id}/seen`, {replyAt: 'x'}, as(admin))).status, 403);
257
+ assert.equal((await call(manager, 'POST', `${P}/send`, {title: 't'}, as(me))).status, 400);
258
+
259
+ // Its own list: only the manager's reports, sender-safe fields, same as a site sees.
260
+ let mine = (await call(manager, 'GET', `${P}/sent`, null, as(me))).body;
261
+ assert.equal(mine.connected, true);
262
+ assert.deepEqual(mine.reports.map(x => x.title), ['Manager hiccup']);
263
+ for (const k of ['notes', 'userEmail', 'site', 'history']) assert.equal(mine.reports[0][k], undefined, `${k} stays in the inbox`);
264
+
265
+ // The same reply flow: another level-0 holder answers; it shows unread until opened.
266
+ r = await call(manager, 'PUT', `${P}/reports/${got.id}`, {status: 'planned', reply: 'Next release.', notes: 'css'},
267
+ as({id: 'v2', name: 'Other', role: TOP}));
268
+ assert.equal(r.status, 200);
269
+ mine = (await call(manager, 'GET', `${P}/sent`, null, as(me))).body.reports[0];
270
+ assert.equal(mine.statusLabel, 'Planned');
271
+ assert.equal(mine.reply, 'Next release.');
272
+ assert.equal(mine.unread, true);
273
+ assert.equal((await call(manager, 'GET', `${P}/count`, null, as(me))).body.details.rows[1].value, '1');
274
+ await call(manager, 'PUT', `${P}/sent/${mine.id}/seen`, {replyAt: mine.replyAt}, as(me));
275
+ assert.equal((await call(manager, 'GET', `${P}/sent`, null, as(me))).body.reports[0].unread, false);
276
+
277
+ // Replying to your own report is not news to you.
278
+ await call(manager, 'PUT', `${P}/reports/${got.id}`, {reply: 'Done it myself.'}, as(me));
279
+ assert.equal((await call(manager, 'GET', `${P}/sent`, null, as(me))).body.reports[0].unread, false);
280
+
281
+ // And Waypoint: the same promote path.
282
+ globalThis.dommaWaypoint = {version: 1, listProjects: async () => [], getIssue: async () => null,
283
+ createIssue: async (input) => ({id: 'i9', key: 'CMS-99', made: input})};
284
+ r = await call(manager, 'POST', `${P}/reports/${got.id}/promote`, {projectId: 'p1', type: 'bug'});
285
+ delete globalThis.dommaWaypoint;
286
+ assert.equal(r.status, 200, JSON.stringify(r.body));
287
+ assert.equal(r.body.issueKey, 'CMS-99');
288
+
289
+ // No site token can speak as the manager.
290
+ assert.equal((await call(manager, 'GET', `${P}/fleet/mine`, null, {authorization: 'Bearer _manager'})).status, 401);
291
+ });