domma-cms 1.6.36 → 1.6.38

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 (84) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/package.json +2 -2
  4. package/plugins/_lib/admin/mail/compose-window.js +4 -0
  5. package/plugins/_lib/admin/mail/mail.css +1 -1
  6. package/plugins/_lib/admin/mail/reader-view.js +15 -6
  7. package/plugins/_lib/mail/compose.js +7 -2
  8. package/plugins/_lib/mail/message-action.js +56 -0
  9. package/plugins/_lib/mail/scheduler.js +2 -0
  10. package/plugins/_lib/mail/send.js +12 -3
  11. package/plugins/_lib/mail/store.js +16 -6
  12. package/plugins/_lib/outreach/index.js +1 -1
  13. package/plugins/_lib/outreach/ledger.js +10 -3
  14. package/plugins/_lib/outreach/sending.js +9 -3
  15. package/plugins/_lib/outreach/suppression.js +14 -3
  16. package/plugins/_lib/outreach/unsubscribe.js +13 -0
  17. package/plugins/blog/CLAUDE.md +11 -1
  18. package/plugins/blog/lib/cache.js +43 -0
  19. package/plugins/blog/lib/page.js +28 -9
  20. package/plugins/blog/plugin.js +14 -13
  21. package/plugins/blog/plugin.json +1 -1
  22. package/plugins/blog/plugin.public.js +1 -1
  23. package/plugins/blog/tests/cache.test.js +65 -0
  24. package/plugins/booking/CLAUDE.md +14 -2
  25. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  26. package/plugins/booking/admin/views/diary.js +153 -152
  27. package/plugins/booking/admin/views/people.js +64 -66
  28. package/plugins/booking/admin/views/settings.js +31 -32
  29. package/plugins/booking/plugin.json +1 -1
  30. package/plugins/booking/tests/admin-diary.test.js +9 -1
  31. package/plugins/feedback/CLAUDE.md +55 -0
  32. package/plugins/feedback/admin/css/index.css +16 -1
  33. package/plugins/feedback/admin/lib/kit.js +106 -0
  34. package/plugins/feedback/admin/lib/settings.js +107 -0
  35. package/plugins/feedback/admin/views/feedback.js +4 -1
  36. package/plugins/feedback/admin/views/inbox.js +11 -2
  37. package/plugins/feedback/admin/views/send.js +74 -7
  38. package/plugins/feedback/docs/guide.md +9 -9
  39. package/plugins/feedback/lib/attachments.js +133 -0
  40. package/plugins/feedback/lib/cache.js +43 -0
  41. package/plugins/feedback/lib/receiver.js +36 -6
  42. package/plugins/feedback/lib/sender.js +70 -6
  43. package/plugins/feedback/lib/shape.js +111 -6
  44. package/plugins/feedback/plugin.js +171 -17
  45. package/plugins/feedback/plugin.json +2 -2
  46. package/plugins/feedback/tests/api.test.js +125 -3
  47. package/plugins/feedback/tests/settings.test.js +59 -0
  48. package/plugins/feedback/tests/shape.test.js +64 -0
  49. package/plugins/free-tier.lock.json +73 -61
  50. package/plugins/mail-reader/CLAUDE.md +15 -4
  51. package/plugins/mail-reader/plugin.json +1 -1
  52. package/plugins/shopping-cart/CLAUDE.md +28 -2
  53. package/plugins/shopping-cart/admin/css/index.css +3 -0
  54. package/plugins/shopping-cart/admin/views/orders.js +42 -10
  55. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  56. package/plugins/shopping-cart/docs/guide.md +7 -3
  57. package/plugins/shopping-cart/lib/payments.js +15 -2
  58. package/plugins/shopping-cart/lib/refunds.js +51 -0
  59. package/plugins/shopping-cart/lib/sales.js +15 -8
  60. package/plugins/shopping-cart/lib/shop.js +38 -3
  61. package/plugins/shopping-cart/plugin.js +47 -19
  62. package/plugins/shopping-cart/plugin.json +2 -2
  63. package/plugins/shopping-cart/tests/api.test.js +52 -0
  64. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  65. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  66. package/plugins/support/CLAUDE.md +51 -0
  67. package/plugins/support/admin/css/index.css +16 -1
  68. package/plugins/support/admin/lib/kit.js +192 -8
  69. package/plugins/support/admin/lib/settings.js +123 -0
  70. package/plugins/support/admin/views/desk.js +55 -12
  71. package/plugins/support/admin/views/inbox.js +17 -4
  72. package/plugins/support/admin/views/support.js +4 -1
  73. package/plugins/support/docs/guide.md +7 -9
  74. package/plugins/support/lib/attachments.js +133 -0
  75. package/plugins/support/lib/cache.js +43 -0
  76. package/plugins/support/lib/receiver.js +45 -10
  77. package/plugins/support/lib/sender.js +81 -10
  78. package/plugins/support/lib/shape.js +153 -12
  79. package/plugins/support/plugin.js +159 -29
  80. package/plugins/support/plugin.json +2 -2
  81. package/plugins/support/tests/api.test.js +125 -3
  82. package/plugins/support/tests/settings.test.js +68 -0
  83. package/plugins/support/tests/shape.test.js +51 -1
  84. package/server/routes/outreach-public.js +54 -10
@@ -13,6 +13,13 @@ import defaultConfig from '../config.js';
13
13
  import {fmtDate} from '../../_lib/admin/ui/dates.js';
14
14
  import {buildToc, byNewest, cardVars, cleanBasePath, DEFAULT_POST_LAYOUT, DISPLAY_DEFAULTS, escapeHtml, interpolate, pickPostLayout, relatedPosts, safeImageUrl} from './layouts.js';
15
15
  import {catalogue, loadBlock, renderCards, styleTag} from './render.js';
16
+ import {createCache} from './cache.js';
17
+
18
+ // Whole-collection reads and author names, kept a short while (lib/cache.js):
19
+ // every public page view and every badge poll read all posts, categories and
20
+ // comments. plugin.js calls forgetEntries() on every write route of this plugin.
21
+ const remembered = createCache();
22
+ export const forgetEntries = () => remembered.forget();
16
23
 
17
24
  /**
18
25
  * Whether readers see a post. Blog Pro files posts under several blogs
@@ -69,12 +76,21 @@ export function liveBase(registered) {
69
76
  }
70
77
 
71
78
  /** Always returns the actual array of entries, regardless of adapter shape. */
72
- export async function loadEntries(slug) {
79
+ async function readEntries(slug) {
73
80
  const result = await listEntries(slug, {limit: 100000, sort: 'createdAt', order: 'desc'});
74
81
  const arr = Array.isArray(result) ? result : (Array.isArray(result?.entries) ? result.entries : []);
75
82
  return arr.map((entry) => ({id: entry.id, ...(entry.data ?? entry)}));
76
83
  }
77
84
 
85
+ /**
86
+ * A collection's entries, newest first - from the cache when a read is fresh.
87
+ * Callers get the SAME array, so none may change it in place (they filter and
88
+ * sort copies, as before).
89
+ */
90
+ export function loadEntries(slug) {
91
+ return remembered.get(`entries:${slug}`, () => readEntries(slug));
92
+ }
93
+
78
94
  /** Render a post body through the full Markdown + shortcode pipeline. */
79
95
  export async function renderPostBody(content) {
80
96
  if (!content) return {html: '', usedComponents: []};
@@ -87,14 +103,17 @@ export async function renderPostBody(content) {
87
103
  }
88
104
  }
89
105
 
90
- export async function resolveAuthorName(authorId) {
91
- if (!authorId) return 'Unknown';
92
- try {
93
- const user = await getUserById(authorId);
94
- return user?.name || user?.email || 'Unknown';
95
- } catch {
96
- return 'Unknown';
97
- }
106
+ /** A user's display name - one lookup per author per cache window, not per card. */
107
+ export function resolveAuthorName(authorId) {
108
+ if (!authorId) return Promise.resolve('Unknown');
109
+ return remembered.get(`author:${authorId}`, async () => {
110
+ try {
111
+ const user = await getUserById(authorId);
112
+ return user?.name || user?.email || 'Unknown';
113
+ } catch {
114
+ return 'Unknown';
115
+ }
116
+ });
98
117
  }
99
118
 
100
119
  /** One lookup per author, however many posts they wrote. */
@@ -5,7 +5,7 @@ import {getPluginStates, savePluginState} from '../../server/services/plugins.js
5
5
  import {renderBlogPage} from '../../server/services/renderer.js';
6
6
  import {cleanBasePath, DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
7
7
  import {catalogue, seedBlocks} from './lib/render.js';
8
- import {liveBase, liveSettings, loadEntries as loadAll, renderPostPage, slugHolder} from './lib/page.js';
8
+ import {forgetEntries, liveBase, liveSettings, loadEntries as loadAll, renderPostPage, slugHolder} from './lib/page.js';
9
9
  import {addNavLink, moveNavLink, removeNavLink} from './lib/nav-link.js';
10
10
  import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
11
11
  import {commentKey, commentNotice} from './lib/notices.js';
@@ -167,17 +167,9 @@ function isVisible(post) {
167
167
  || (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
168
168
  }
169
169
 
170
- async function loadPosts() {
171
- const result = await listEntries(POSTS_SLUG, { limit: 100000, sort: 'createdAt', order: 'desc' });
172
- const arr = Array.isArray(result) ? result : (result?.entries || []);
173
- return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
174
- }
175
-
176
- async function loadCategories() {
177
- const result = await listEntries(CATEGORIES_SLUG, { limit: 10000 });
178
- const arr = Array.isArray(result) ? result : (result?.entries || []);
179
- return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
180
- }
170
+ // The shortcodes run on public page views: the same cached reads as the pages.
171
+ const loadPosts = () => loadAll(POSTS_SLUG);
172
+ const loadCategories = () => loadAll(CATEGORIES_SLUG);
181
173
 
182
174
  function selectPosts(posts, { limit, categorySlug, categoryMap }) {
183
175
  let filtered = posts.filter(isVisible);
@@ -337,6 +329,15 @@ export default async function blogPlugin(fastify, options) {
337
329
  });
338
330
  }
339
331
 
332
+ // Every write route of this plugin (posts, categories, comments - the public
333
+ // POST /comments too - samples) retires the cached reads behind the public
334
+ // pages, the shortcodes and the badge. onSend, not onResponse: the memory is
335
+ // gone BEFORE the writer's response leaves, so their very next read is fresh.
336
+ // The hook is encapsulated - only this plugin's routes reach it.
337
+ fastify.addHook('onSend', async (request) => {
338
+ if (request.method !== 'GET' && request.method !== 'HEAD') forgetEntries();
339
+ });
340
+
340
341
  // The layouts are blocks; put the shipped ones where the Blocks tool can
341
342
  // edit them. Once each, never over a site's own copy (lib/render.js).
342
343
  try {
@@ -392,7 +393,7 @@ export default async function blogPlugin(fastify, options) {
392
393
  });
393
394
  const html = await renderBlogPage(
394
395
  fileURLToPath(new URL('./templates/post.html', import.meta.url)),
395
- { post: page.html, assetV: '1.9.6' },
396
+ { post: page.html, assetV: '1.9.7' },
396
397
  { title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
397
398
  usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
398
399
  );
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.9.6",
4
+ "version": "1.9.7",
5
5
  "tier": "free",
6
6
  "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
7
7
  "author": "Domma CMS",
@@ -24,7 +24,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
24
24
  const TEMPLATES_DIR = path.join(__dirname, 'templates');
25
25
 
26
26
  /** Bumped with the plugin, so a release is never served a stale script. */
27
- const ASSET_V = '1.9.6';
27
+ const ASSET_V = '1.9.7';
28
28
 
29
29
  /**
30
30
  * Above this many posts a listing pages on the server instead: the browser
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Blog - the short memory behind the public pages and the badge (lib/cache.js),
3
+ * and the rule that matters: a write is seen by the very next read.
4
+ */
5
+ import {test} from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import Fastify from 'fastify';
8
+ import {createCache} from '../lib/cache.js';
9
+
10
+ test('a read is shared for the cache window, then read again', async () => {
11
+ let now = 0;
12
+ let reads = 0;
13
+ const c = createCache({ttl: 30_000, clock: () => now});
14
+ const load = async () => { reads++; return ['post']; };
15
+ const [a, b] = await Promise.all([c.get('posts', load), c.get('posts', load)]);
16
+ assert.equal(reads, 1, 'concurrent misses share one read');
17
+ assert.equal(a, b, 'the same array - callers must not change it in place');
18
+ now = 29_999;
19
+ await c.get('posts', load);
20
+ assert.equal(reads, 1);
21
+ now = 30_000;
22
+ await c.get('posts', load);
23
+ assert.equal(reads, 2, 'the window passed: read again');
24
+ });
25
+
26
+ test('forget() retires every key, and a read in flight when the write landed is not kept', async () => {
27
+ const c = createCache({ttl: 60_000});
28
+ let value = 'old';
29
+ let release;
30
+ const slow = () => new Promise(r => { release = r; });
31
+ const inFlight = c.get('posts', slow);
32
+ await c.get('categories', async () => ['cat']);
33
+ assert.equal(c.size, 2);
34
+ // The write finishes while the read is still on its way.
35
+ value = 'new';
36
+ c.forget();
37
+ release('old');
38
+ assert.equal(await inFlight, 'old', 'the caller who started before the write still gets what they read');
39
+ assert.equal(await c.get('posts', async () => value), 'new', 'the next read is fresh, not the stale one');
40
+ assert.deepEqual(await c.get('categories', async () => ['fresh']), ['fresh']);
41
+ });
42
+
43
+ test('a failed read is not remembered', async () => {
44
+ const c = createCache();
45
+ await assert.rejects(() => c.get('posts', async () => { throw new Error('adapter down'); }), /adapter down/);
46
+ assert.equal(c.size, 0);
47
+ assert.deepEqual(await c.get('posts', async () => ['ok']), ['ok']);
48
+ });
49
+
50
+ test('the plugin forgets on every write route, before the writer\'s response leaves', async () => {
51
+ // The hook as plugin.js registers it, on a stand-in app: the point under test
52
+ // is the method rule and that it fires in onSend (the writer's next read is fresh).
53
+ const c = createCache();
54
+ const app = Fastify();
55
+ app.addHook('onSend', async (request) => { if (request.method !== 'GET' && request.method !== 'HEAD') c.forget(); });
56
+ app.get('/posts', async () => c.get('posts', async () => ({at: Date.now()})));
57
+ app.put('/posts/1', async () => ({ok: true}));
58
+ await c.get('posts', async () => 'stale');
59
+ assert.equal(c.size, 1);
60
+ await app.inject({method: 'GET', url: '/posts'});
61
+ assert.equal(c.size, 1, 'a GET keeps the memory');
62
+ await app.inject({method: 'PUT', url: '/posts/1', payload: {}});
63
+ assert.equal(c.size, 0, 'a write retires it');
64
+ await app.close();
65
+ });
@@ -17,8 +17,8 @@ Calendar events block their times (read from `calendar-occurrences` / `calendar-
17
17
  service - no import of the Calendar, which is a separate paid Tool).
18
18
 
19
19
  `tier: free`, ships with the engine, disabled on arrival. Plan and phases: `dcms-marketplace/docs/booking-plan.md`.
20
- Booking Pro (not yet built) is an ADD-ON that registers into this one (`lib/extensions.js`), the Cart / Cart Pro
21
- pattern - it does not supersede it. Payment is Pro only.
20
+ Booking Pro (marketplace `booking-pro`, 0.4.x) is an ADD-ON that registers into this one (`lib/extensions.js`), the
21
+ Cart / Cart Pro pattern - it does not supersede it. Neither takes payment yet.
22
22
 
23
23
  1.6.0 (2026-09-30): "Add people" in an online booking's call (core with `server/services/callInvites.js` and
24
24
  `_lib/call/invite.js`; both imported dynamically - lib/call.js by top-level `await import`, plugin.js beside
@@ -168,3 +168,15 @@ only to roles that hold it.
168
168
  the run (restored after) and forges a Host to prove it.
169
169
  - The API tests create contacts in domma-cms's `content/collections/contacts-contacts/data.json` (tracked) - they
170
170
  snapshot and restore it; a crashed run can leave it changed: `git checkout` it in domma-cms.
171
+
172
+ ## 1.6.4 (2026-10-13 Tool sweep, upgrades)
173
+ - The Diary is domma-reactive end to end: heads, hour gutter and columns are keyed `data-each` lists (`HEAD`, `HOUR`,
174
+ `COL` in diary.js; each column's open bands and blocks are `{{#each}}` inside it). One `draw` effect works out every
175
+ measure as a CSS length (`blockRow`) and the markup only binds; the empty state, "add a service" tip and first-run
176
+ checklist (`STEP`) are bound too. Kebab / right-click / Shift+F10 still come from `wireRows(gridBox, {match: '.bk-block'})`
177
+ (delegated, so re-rendered blocks need no rewiring). `mergeWindows` moved to admin/lib/diary-layout.js (tested).
178
+ - Settings > Closed days (`CLOSURE_ROW`) and a person's opening hours / days off (`DAY_ROW` with a nested
179
+ `{{#each windows}}`, `OFF_ROW`) are keyed lists of per-field observables; `hoursOut` / `daysOffOut` / `closuresOut`
180
+ turn them back into plain data for the dirty snapshot and the save. A "from" after the "to" still pulls "to" along.
181
+ - Icons in rows added after binding are drawn by `I.scan` after a flush (`rescan`), because new rows arrive after
182
+ the panel's first scan.
@@ -138,3 +138,21 @@ export function columnItems(bookings, {bookableId = ''} = {}) {
138
138
  }
139
139
  return [...groups.values()].map(g => ({...g, session: g.bookings.length > 1}));
140
140
  }
141
+
142
+ /**
143
+ * Opening windows (minutes) merged where they touch or overlap - the week view's one band per day for everyone.
144
+ * Moved here from diary.js (1.6.4) so it is tested with the rest of the geometry.
145
+ *
146
+ * @param {{from: number, to: number}[]} list
147
+ * @returns {{from: number, to: number}[]} sorted, non-overlapping copies
148
+ */
149
+ export function mergeWindows(list) {
150
+ const sorted = [...(list || [])].sort((a, b) => a.from - b.from);
151
+ const out = [];
152
+ for (const w of sorted) {
153
+ const last = out[out.length - 1];
154
+ if (last && w.from <= last.to) last.to = Math.max(last.to, w.to);
155
+ else out.push({...w});
156
+ }
157
+ return out;
158
+ }