domma-cms 0.91.1 → 0.92.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.91.1",
3
+ "version": "0.92.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -0,0 +1,96 @@
1
+ # feedback - AI Assistant Guide
2
+
3
+ ## Purpose
4
+
5
+ Site admins tell Domma (the vendor) what is broken, missing or good, from their own admin, and see
6
+ what became of it. Free tier: it ships in the CMS (synced by `make sync-free-tier`).
7
+
8
+ ONE plugin, TWO sides, chosen at registration by where it runs:
9
+
10
+ - **sender** - any site the manager started (it has `MANAGER_URL` + `FLEET_TOKEN` in its environment,
11
+ set by site-manager's processManager). The screen is "Send feedback" plus this site's reports.
12
+ - **receiver** - the manager (dcm). Detected by `plugins/site-manager/services/fleetTokens.js` existing
13
+ beside this plugin (`lib/receiver.js isManager()`). The screen is the inbox of every site's reports.
14
+
15
+ A site the manager did not start (a standalone install) has nowhere to send to: the screen says so,
16
+ `POST /send` answers 409.
17
+
18
+ ## Files
19
+
20
+ - `plugin.js` - both route sets; which one registers is decided once at boot (`options.mode` overrides in tests)
21
+ - `lib/shape.js` - PURE: types, severities, statuses, `cleanSubmission`, `buildPayload`, `receivedReport`,
22
+ `senderView` (the whitelist), `cleanUpdate`, `statusFromIssue`, `createThrottle`, `issueDescription`
23
+ - `lib/sender.js` - the outbox (`data/outbox.json`), `managerApi` (fetch, injectable), `flush`, `mergeReports`
24
+ - `lib/receiver.js` - the `feedback-reports` collection, token -> slug, site names, patches with history
25
+ - `collections/feedback-reports.json` - schema, created by `ensureCollection()` on the manager only
26
+ - `admin/views/feedback.js` - the shell: asks `/mode`, loads `send.js` or `inbox.js`
27
+ - `admin/lib/kit.js`, `admin/templates/feedback.html`, `admin/css/index.css`
28
+ - `tests/shape.test.js` (pure), `tests/api.test.js` (a sender app wired into a receiver app via inject)
29
+
30
+ ## Routes (`/api/plugins/feedback`)
31
+
32
+ Sender (permission `feedback` action `send`):
33
+
34
+ | Method | Path | Purpose |
35
+ |---|---|---|
36
+ | GET | `/mode` | `{mode:'sender', connected, site}` |
37
+ | GET | `/meta` | types, statuses, severities |
38
+ | GET | `/count` | sidebar badge: replies not yet opened (from the outbox copy - never calls the manager) |
39
+ | GET | `/sent` | sends anything waiting, then the manager's `/fleet/mine` merged with the outbox |
40
+ | POST | `/send` | a report: written to the outbox FIRST, then sent; answers `state: sent|waiting` |
41
+ | PUT | `/sent/:id/seen` | `{replyAt}` - this reply has been read |
42
+
43
+ Receiver (inbox routes: permission `feedback` action `manage`):
44
+
45
+ | Method | Path | Purpose |
46
+ |---|---|---|
47
+ | POST | `/fleet/submit` | a site's report. **No session**: `Authorization: Bearer <fleet token>` names the site. bodyLimit 32 KB, per-site throttle |
48
+ | GET | `/fleet/mine` | that site's reports, `senderView()` fields only |
49
+ | GET | `/mode` | `{mode:'receiver', waypoint}` |
50
+ | GET | `/count` | badge: status `new` (danger tone if any is a blocker) |
51
+ | GET | `/reports` · `/reports/:id` | the inbox; reading one freshens its Waypoint status |
52
+ | PUT | `/reports/:id` | `{status?, reply?, notes?}` |
53
+ | DELETE | `/reports/:id` | gone for the site too |
54
+ | GET | `/waypoint` | `{available, projects}` |
55
+ | POST | `/reports/:id/promote` | `{projectId, type, priority}` -> a Waypoint issue; 503 without Waypoint, 409 if already linked |
56
+
57
+ ## Storage
58
+
59
+ - Manager: collection `feedback-reports` (file adapter), NOT `feedback` - that slug is the starter preset
60
+ every site has for visitor comments. All four API verbs are OFF (a collection's default is a PUBLIC
61
+ read, and these carry admins' names and emails). Record: site, ref, type, title, description, where,
62
+ severity, cmsVersion, userId/userName/userEmail, status, reply, replyAt, notes, issueId, issueKey,
63
+ issueStatus, history[].
64
+ - Site: `data/outbox.json` - `{items: [{ref, state: waiting|sent|refused, payload, remoteId, reply, replyAt,
65
+ lastError}], seen: {reportId: replyAt}}`. The engine updater preserves plugin `data/`.
66
+
67
+ ## Waypoint Pro integration (feature-detected both ways; neither imports the other)
68
+
69
+ - Waypoint sets `globalThis.dommaWaypoint = {version: 1, listProjects(), createIssue({projectId, title,
70
+ description, type, priority, reporterName, source: {plugin: 'feedback', id, site, url}}), getIssue(idOrKey)}`.
71
+ - Feedback (receiver) sets `globalThis.dommaFeedback = {version: 1, issueUpdated({sourceId, issue: {id, key,
72
+ statusName, statusCategory}})}`. Waypoint calls it when a linked issue moves.
73
+ - `statusFromIssue`: todo -> planned, doing -> in-progress, done -> done; a report set to `declined`
74
+ (shown "Not planned") by hand keeps it.
75
+ - The inbox offers issue types bug/story/task and priorities highest..lowest as plain strings.
76
+
77
+ ## Gotchas
78
+
79
+ - **Who sent it is never the browser's word.** The sender builds the payload from `request.user`; the
80
+ manager attributes a report to the slug the fleet TOKEN resolves to and ignores any `site` in the body.
81
+ A site can still vouch for any of its own admins - it cannot speak for another site.
82
+ - **Tokens rotate whenever either end restarts** (fleetTokens.js is memory-only), so a send can meet a
83
+ 401 or ECONNREFUSED for a few seconds. That is why the outbox exists: a report is "sent or waiting",
84
+ never lost. Waiting ones go on the next `/sent` and on the timer (`syncMinutes`, default 60, which also
85
+ notices new replies and raises a `feedback:reply` notification). A retry carries the same `ref`, and the
86
+ manager answers the existing report (200) instead of making a second.
87
+ - **The global rate limiter exempts loopback**, which is where every site calls from, so `/fleet/submit`
88
+ has its own per-site throttle (`perSiteMax` in `perSiteMinutes`, default 10 in 10). A deduplicated
89
+ retry counts too.
90
+ - **Help popovers are wired BEFORE the row template goes in.** `helpPopovers()` scans icons; a template
91
+ scanned while its icon is still `{{icon}}` is marked `data-icon-processed`, every row cloned from it
92
+ inherits the mark and stays blank. Rows are rescanned (after `M.flush()`) whenever they change.
93
+ - **`[hidden]` needs its own rule.** `data-bind-hidden` sets the attribute, and any `display` rule
94
+ (`.fb-note` is flex) beats the browser's. `index.css` carries `.fb [hidden] {display: none !important}`.
95
+ - Only this machine's sites can reach dcm (the fleet token lives in this manager's memory). Sites on other
96
+ machines would need their own manager to relay with an installation key; not built.
@@ -0,0 +1,80 @@
1
+ /* Feedback - admin styles. Theme tokens only (every one below is defined by every Domma theme). */
2
+
3
+ /* data-bind-hidden sets [hidden]; any display rule below would otherwise beat it. */
4
+ .fb [hidden], .fb-detail [hidden], .fb-form [hidden] { display: none !important; }
5
+
6
+ .fb { max-width: 60rem; }
7
+ .fb-card { background: var(--dm-surface); border: 1px solid var(--dm-border); border-radius: var(--dm-radius-lg); padding: 1rem 1.1rem; }
8
+ .fb-bar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .8rem; }
9
+ .fb-spacer { flex: 1; }
10
+ .fb-intro { display: flex; flex-direction: column; gap: .15rem; }
11
+ .fb-intro span { color: var(--dm-text-secondary); font-size: var(--dm-font-size-sm); }
12
+
13
+ .fb-note { margin: .5rem 0; color: var(--dm-text-secondary); font-size: var(--dm-font-size-sm); display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
14
+ .fb-note.is-bad { color: var(--dm-danger); }
15
+ .fb-note.is-warn { color: var(--dm-text); background: color-mix(in srgb, var(--dm-warning) 14%, transparent); border-radius: var(--dm-radius-md); padding: .45rem .7rem; }
16
+ .fb-hint { margin: .2rem 0 0; color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
17
+
18
+ .fb-empty-state { text-align: center; padding: 2.5rem 1rem; color: var(--dm-text-secondary); }
19
+ .fb-empty-state h3 { margin: .6rem 0 .4rem; color: var(--dm-text); }
20
+ .fb-empty-state p { max-width: 34rem; margin: 0 auto; }
21
+
22
+ /* The status chips and filters */
23
+ .fb-seg { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
24
+ .fb-seg button { border: 1px solid var(--dm-border); background: none; color: var(--dm-text-secondary); border-radius: var(--dm-radius-full);
25
+ padding: .2rem .7rem; font-size: var(--dm-font-size-sm); cursor: pointer; }
26
+ .fb-seg button b { font-weight: 600; margin-left: .2rem; }
27
+ .fb-seg button.is-on { background: var(--dm-primary-light); color: var(--dm-text); border-color: var(--dm-primary); }
28
+ .fb-filter { width: auto; min-width: 10rem; }
29
+ .fb-search { flex: 1; min-width: 12rem; }
30
+
31
+ /* Rows */
32
+ .fb-list { display: flex; flex-direction: column; }
33
+ .fb-row { display: flex; align-items: center; gap: .75rem; padding: .6rem .5rem; border-top: 1px solid var(--dm-border); cursor: pointer; border-radius: var(--dm-radius-md); }
34
+ .fb-row:first-child { border-top: 0; }
35
+ .fb-row:hover, .fb-row:focus-visible { background: var(--dm-hover-bg); outline: none; }
36
+ .fb-type { display: inline-flex; color: var(--dm-text-secondary); }
37
+ .fb-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
38
+ .fb-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
39
+ .fb-main small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
40
+ .fb-key { font-family: var(--dm-font-mono, monospace); font-size: var(--dm-font-size-xs); color: var(--dm-text-secondary); }
41
+ .fb-dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--dm-primary); flex: none; }
42
+ .fb-kebab { border: 0; background: none; color: var(--dm-text-secondary); cursor: pointer; font-size: 1.1rem; padding: 0 .35rem; border-radius: var(--dm-radius-md); }
43
+ .fb-kebab:hover { background: var(--dm-hover-bg); color: var(--dm-text); }
44
+
45
+ .fb-chip { font-size: var(--dm-font-size-xs); font-weight: 600; padding: .12rem .55rem; border-radius: var(--dm-radius-full); white-space: nowrap;
46
+ background: color-mix(in srgb, var(--dm-text-secondary) 14%, transparent); color: var(--dm-text); }
47
+ .fb-chip.is-info { background: color-mix(in srgb, var(--dm-info) 18%, transparent); }
48
+ .fb-chip.is-warning { background: color-mix(in srgb, var(--dm-warning) 22%, transparent); }
49
+ .fb-chip.is-success { background: color-mix(in srgb, var(--dm-success) 20%, transparent); }
50
+ .fb-chip.is-bad { background: color-mix(in srgb, var(--dm-danger) 20%, transparent); }
51
+
52
+ /* Forms and panels */
53
+ .fb-form { display: flex; flex-direction: column; gap: .35rem; }
54
+ .fb-form .form-label { margin-top: .5rem; }
55
+ .fb-types { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: .4rem; }
56
+ .fb-types button { display: flex; align-items: center; gap: .45rem; border: 1px solid var(--dm-border); background: none; color: var(--dm-text);
57
+ border-radius: var(--dm-radius-md); padding: .5rem .6rem; cursor: pointer; text-align: left; font-size: var(--dm-font-size-sm); }
58
+ .fb-types button:hover { background: var(--dm-hover-bg); }
59
+ .fb-types button.is-on { border-color: var(--dm-primary); background: var(--dm-primary-light); }
60
+ .fb-actions { display: flex; gap: .5rem; align-items: center; margin-top: .8rem; }
61
+
62
+ .fb-detail h4 { margin: 1.2rem 0 .4rem; display: flex; align-items: center; gap: .4rem; }
63
+ .fb-detail h4 small { color: var(--dm-text-secondary); font-weight: 400; }
64
+ .fb-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: .5rem 1rem; margin: 0; }
65
+ .fb-facts dt { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
66
+ .fb-facts dd { margin: 0; overflow-wrap: anywhere; }
67
+ .fb-text { white-space: pre-wrap; overflow-wrap: anywhere; background: var(--dm-background); border: 1px solid var(--dm-border);
68
+ border-radius: var(--dm-radius-md); padding: .7rem .8rem; }
69
+ .fb-reply { border-color: var(--dm-primary); }
70
+ .fb-waypoint { margin-top: 1rem; padding: .7rem .8rem; border: 1px dashed var(--dm-border); border-radius: var(--dm-radius-md); }
71
+ .fb-waypoint h4 { margin-top: 0; }
72
+ .fb-grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .6rem; }
73
+ .fb-history { list-style: none; margin: 0; padding: 0; font-size: var(--dm-font-size-sm); }
74
+ .fb-history li { display: flex; justify-content: space-between; gap: 1rem; padding: .3rem 0; border-top: 1px solid var(--dm-border); }
75
+ .fb-history small { color: var(--dm-text-secondary); }
76
+
77
+ @media (max-width: 640px) {
78
+ .fb-grid3 { grid-template-columns: 1fr; }
79
+ .fb-row { flex-wrap: wrap; }
80
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Feedback admin - what both screens share: the admin's helpers (loaded at run
3
+ * time; a plugin never imports admin/ statically - the updater replaces it),
4
+ * the API, escaping, dates and slideovers.
5
+ *
6
+ * @module feedback/admin/lib/kit
7
+ */
8
+
9
+ export const SIDEBAR_URL = '#/plugins/feedback';
10
+ export const BASE = '/api/plugins/feedback';
11
+
12
+ let kit = null;
13
+
14
+ /** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
15
+ export function loadKit() {
16
+ if (!kit) {
17
+ kit = Promise.all([
18
+ import('/admin/js/lib/tool-kit.js').catch(() => ({})),
19
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
20
+ import('/admin/js/lib/help-popover.js').catch(() => ({}))
21
+ ]).then(([tk, chrome, help]) => ({
22
+ onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
23
+ openMenuAt: tk.openMenuAt || ((el) => {
24
+ const r = el.getBoundingClientRect();
25
+ el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
26
+ }),
27
+ viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
28
+ addBannerAction: chrome.addBannerAction || (() => null),
29
+ helpPopovers: help.helpPopovers || (() => () => {})
30
+ }));
31
+ }
32
+ return kit;
33
+ }
34
+
35
+ const seg = (id) => encodeURIComponent(String(id));
36
+ export const api = {
37
+ mode: () => H.get(`${BASE}/mode`),
38
+ meta: () => H.get(`${BASE}/meta`),
39
+ sent: () => H.get(`${BASE}/sent`),
40
+ send: (d) => H.post(`${BASE}/send`, d),
41
+ seen: (id, replyAt) => H.put(`${BASE}/sent/${seg(id)}/seen`, {replyAt}),
42
+ reports: () => H.get(`${BASE}/reports`),
43
+ report: (id) => H.get(`${BASE}/reports/${seg(id)}`),
44
+ update: (id, d) => H.put(`${BASE}/reports/${seg(id)}`, d),
45
+ remove: (id) => H.delete(`${BASE}/reports/${seg(id)}`),
46
+ waypoint: () => H.get(`${BASE}/waypoint`),
47
+ promote: (id, d) => H.post(`${BASE}/reports/${seg(id)}/promote`, d)
48
+ };
49
+
50
+ /** E.confirm and slideover titles render HTML: user text goes in escaped. */
51
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
52
+
53
+ /** A small DOM tree from an HTML string that holds NO unescaped user data. */
54
+ export function fragment(html) {
55
+ const t = document.createElement('template');
56
+ t.innerHTML = html.trim();
57
+ return t.content.firstElementChild;
58
+ }
59
+
60
+ export const store = {
61
+ get(k) { try { return S.get(k); } catch { return null; } },
62
+ set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } }
63
+ };
64
+
65
+ /** The sidebar re-asks the Feedback badge. */
66
+ export function refreshBadge() {
67
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
68
+ }
69
+
70
+ /** "3 min ago", "26 Sep". */
71
+ export function when(iso) {
72
+ const t = new Date(iso).getTime();
73
+ if (!Number.isFinite(t)) return '';
74
+ const s = (Date.now() - t) / 1000;
75
+ if (s < 60) return 'just now';
76
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
77
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
78
+ if (s < 86400 * 6) return `${Math.round(s / 86400)} d ago`;
79
+ return new Date(t).toLocaleDateString(undefined, {day: 'numeric', month: 'short', ...(s > 86400 * 300 ? {year: 'numeric'} : {})});
80
+ }
81
+
82
+ /** Full date and time, for a detail panel. */
83
+ export const stamp = (iso) => {
84
+ const t = new Date(iso);
85
+ return Number.isFinite(t.getTime()) ? t.toLocaleString(undefined, {dateStyle: 'medium', timeStyle: 'short'}) : '';
86
+ };
87
+
88
+ /**
89
+ * A slideover holding `html`, bound to `model`. Everything is disposed and the
90
+ * element removed on close.
91
+ *
92
+ * @returns {Promise<{so, panel, close: () => void}>}
93
+ */
94
+ export async function openPanel({title, size = 'md', html, model = {}, methods = {}, cleanup = () => {}}) {
95
+ const {onceClosed, helpPopovers} = await loadKit();
96
+ const so = E.slideover({title: esc(title), size, position: 'right'});
97
+ const panel = fragment(html);
98
+ so.element.appendChild(panel);
99
+ const disposeHelp = helpPopovers(panel, {position: 'bottom'});
100
+ const handle = M.applyBindings(model, panel, {methods});
101
+ onceClosed(so, () => { cleanup(); handle.dispose(); disposeHelp(); });
102
+ I.scan(panel);
103
+ so.open();
104
+ return {so, panel, close: () => so.close()};
105
+ }
@@ -0,0 +1 @@
1
+ <div class="fb" data-feedback-admin></div>
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Feedback - one sidebar entry, two screens, decided by the server:
3
+ *
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)
6
+ *
7
+ * #/plugins/feedback the screen
8
+ * #/plugins/feedback/<id> the same, with that report open (a notification's link)
9
+ *
10
+ * Path segments, not ?query: the admin router 404s a query on a fresh load.
11
+ */
12
+
13
+ import {api, loadKit} from '../lib/kit.js';
14
+
15
+ const ROUTE = /^#\/plugins\/feedback(\/|$)/;
16
+ const V = '1.0.0';
17
+ let lifetime = null;
18
+
19
+ export const feedbackView = {
20
+ templateUrl: '/plugins/feedback/admin/templates/feedback.html',
21
+
22
+ async onMount($container) {
23
+ const kit = await loadKit();
24
+ lifetime ??= kit.viewLifetime(ROUTE);
25
+ if (!ROUTE.test(location.hash)) return;
26
+ lifetime.reset();
27
+ const scope = (fn) => { lifetime.add(fn); return fn; };
28
+ const container = $container.get(0);
29
+ const root = container.querySelector('[data-feedback-admin]') || container;
30
+ const mountedFor = location.hash;
31
+ const here = () => location.hash === mountedFor && root.isConnected;
32
+ const m = location.hash.match(/^#\/plugins\/feedback\/([^/?#]+)/);
33
+ const id = m ? decodeURIComponent(m[1]) : '';
34
+
35
+ const fail = (text) => {
36
+ root.innerHTML = '';
37
+ const p = document.createElement('p');
38
+ p.className = 'fb-note is-bad';
39
+ p.textContent = text;
40
+ root.appendChild(p);
41
+ };
42
+
43
+ let mode;
44
+ let meta;
45
+ try {
46
+ [mode, meta] = await Promise.all([api.mode(), api.meta()]);
47
+ } catch (err) {
48
+ fail(`Feedback could not load: ${err.message || err}`);
49
+ return;
50
+ }
51
+ if (!here()) return;
52
+
53
+ const screen = mode.mode === 'receiver' ? 'inbox' : 'send';
54
+ try {
55
+ 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}`);
61
+ }
62
+ }
63
+ };