domma-cms 0.102.1 → 0.104.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 (50) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/admin/mail/message-actions.js +56 -0
  3. package/plugins/_lib/admin/mail/reader-view.js +30 -1
  4. package/plugins/_lib/call/ice.js +58 -0
  5. package/plugins/_lib/call/rooms.js +249 -0
  6. package/plugins/_lib/call/routes.js +89 -0
  7. package/plugins/_lib/public/call/call.css +1 -0
  8. package/plugins/_lib/public/call/call.js +477 -0
  9. package/plugins/booking/CLAUDE.md +137 -0
  10. package/plugins/booking/admin/css/index.css +192 -0
  11. package/plugins/booking/admin/lib/details.js +58 -0
  12. package/plugins/booking/admin/lib/diary-layout.js +140 -0
  13. package/plugins/booking/admin/lib/extension-sections.js +160 -0
  14. package/plugins/booking/admin/lib/kit.js +249 -0
  15. package/plugins/booking/admin/templates/booking.html +1 -0
  16. package/plugins/booking/admin/views/booking.js +207 -0
  17. package/plugins/booking/admin/views/bookings.js +127 -0
  18. package/plugins/booking/admin/views/diary.js +374 -0
  19. package/plugins/booking/admin/views/panels.js +516 -0
  20. package/plugins/booking/admin/views/people.js +281 -0
  21. package/plugins/booking/admin/views/services.js +222 -0
  22. package/plugins/booking/admin/views/settings.js +203 -0
  23. package/plugins/booking/collections/booking-bookables.json +17 -0
  24. package/plugins/booking/collections/booking-bookings.json +18 -0
  25. package/plugins/booking/collections/booking-services.json +17 -0
  26. package/plugins/booking/config.js +36 -0
  27. package/plugins/booking/docs/guide.md +70 -0
  28. package/plugins/booking/inject/body.html +1 -0
  29. package/plugins/booking/lib/call.js +72 -0
  30. package/plugins/booking/lib/extensions.js +169 -0
  31. package/plugins/booking/lib/mail.js +113 -0
  32. package/plugins/booking/lib/store.js +390 -0
  33. package/plugins/booking/plugin.js +806 -0
  34. package/plugins/booking/plugin.json +80 -0
  35. package/plugins/booking/plugin.public.js +157 -0
  36. package/plugins/booking/public/booking.css +159 -0
  37. package/plugins/booking/public/booking.js +808 -0
  38. package/plugins/booking/public/lib/clean.js +141 -0
  39. package/plugins/booking/public/lib/page-format.js +121 -0
  40. package/plugins/booking/public/lib/slots.js +247 -0
  41. package/plugins/booking/public/lib/time.js +131 -0
  42. package/plugins/booking/templates/booking.html +5 -0
  43. package/plugins/booking/tests/admin-diary.test.js +73 -0
  44. package/plugins/booking/tests/api.test.js +445 -0
  45. package/plugins/booking/tests/public.test.js +72 -0
  46. package/plugins/booking/tests/pure.test.js +189 -0
  47. package/plugins/free-tier.lock.json +44 -1
  48. package/server/routes/api/related.js +20 -5
  49. package/server/server.js +3 -3
  50. package/server/services/hooks.js +35 -8
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Booking's pure core: wall-clock time in a zone, and which slots are free.
3
+ * Double booking is the one bug this Tool cannot have, so the slot rules are
4
+ * pinned down here in detail.
5
+ */
6
+ import {test} from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import {addDays, atWallClock, cleanZone, fromMinutes, isDate, toMinutes, wallClock, weekdayOf} from '../public/lib/time.js';
9
+ import {bookingRange, isFree, pickBookable, slotsFor, spanOf, windowsOn} from '../public/lib/slots.js';
10
+
11
+ const LDN = 'Europe/London';
12
+
13
+ test('wall clock <-> instant in London, both sides of the clock changes', () => {
14
+ assert.equal(atWallClock('2031-01-15', '09:00', LDN).toISOString(), '2031-01-15T09:00:00.000Z', 'GMT');
15
+ assert.equal(atWallClock('2031-07-15', '09:00', LDN).toISOString(), '2031-07-15T08:00:00.000Z', 'BST');
16
+ // 2031-03-30: clocks go 01:00 -> 02:00. 01:30 does not exist.
17
+ assert.equal(atWallClock('2031-03-30', '00:30', LDN).toISOString(), '2031-03-30T00:30:00.000Z');
18
+ assert.equal(atWallClock('2031-03-30', '02:30', LDN).toISOString(), '2031-03-30T01:30:00.000Z');
19
+ // 2031-10-26: clocks go 02:00 -> 01:00. 01:30 happens twice; the first is BST.
20
+ assert.equal(atWallClock('2031-10-26', '01:30', LDN).toISOString(), '2031-10-26T00:30:00.000Z');
21
+ assert.equal(atWallClock('2031-10-26', '03:00', LDN).toISOString(), '2031-10-26T03:00:00.000Z');
22
+ assert.deepEqual(wallClock('2031-07-15T08:00:00Z', LDN), {date: '2031-07-15', time: '09:00', weekday: 2, minutes: 540});
23
+ assert.equal(atWallClock('2031-07-15', '09:00', 'America/New_York').toISOString(), '2031-07-15T13:00:00.000Z');
24
+ });
25
+
26
+ test('time helpers', () => {
27
+ assert.equal(toMinutes('09:30'), 570);
28
+ assert.equal(toMinutes('24:00'), 1440);
29
+ assert.ok(Number.isNaN(toMinutes('24:30')));
30
+ assert.ok(Number.isNaN(toMinutes('9')));
31
+ assert.equal(fromMinutes(570), '09:30');
32
+ assert.equal(addDays('2031-12-31', 1), '2032-01-01');
33
+ assert.equal(weekdayOf('2031-07-15'), 2);
34
+ assert.ok(isDate('2032-02-29'));
35
+ assert.ok(!isDate('2031-02-29'));
36
+ assert.equal(cleanZone('Not/AZone'), LDN);
37
+ assert.equal(cleanZone('America/New_York'), 'America/New_York');
38
+ });
39
+
40
+ const SAM = {id: 'sam', hours: {tue: [{from: '09:00', to: '12:00'}, {from: '11:00', to: '13:00'}, {from: '14:00', to: '17:00'}]}, daysOff: [{from: '2031-07-22', to: '2031-07-22'}]};
41
+ const ROOM = {id: 'room', hours: {tue: [{from: '10:00', to: '11:00'}]}};
42
+ const CUT = {id: 'cut', duration: 60, bufferBefore: 0, bufferAfter: 15, step: 30, minNoticeMinutes: 0, maxAdvanceDays: 0};
43
+ const NOW = '2031-07-01T00:00:00Z';
44
+
45
+ test('opening windows: overlapping ones merge; a day off or a closure empties the day', () => {
46
+ assert.deepEqual(windowsOn(SAM, '2031-07-15'), [{from: 540, to: 780}, {from: 840, to: 1020}]);
47
+ assert.deepEqual(windowsOn(SAM, '2031-07-22'), [], 'day off');
48
+ assert.deepEqual(windowsOn(SAM, '2031-07-15', [{from: '2031-07-14', to: '2031-07-16'}]), [], 'closure');
49
+ assert.deepEqual(windowsOn(SAM, '2031-07-16'), [], 'no Wednesday hours');
50
+ assert.deepEqual(windowsOn({hours: {tue: [{from: '10:00', to: '09:00'}, {from: 'x', to: '11:00'}]}}, '2031-07-15'), [], 'nonsense ignored');
51
+ });
52
+
53
+ test('slots: every step that fits in a window, in the business zone', () => {
54
+ const [day] = slotsFor({service: CUT, bookables: [SAM], bookings: [], from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN});
55
+ assert.deepEqual(day.slots.map(s => s.time), ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '12:00', '14:00', '14:30', '15:00', '15:30', '16:00']);
56
+ assert.equal(day.slots[0].start, '2031-07-15T08:00:00.000Z');
57
+ assert.equal(day.slots[0].end, '2031-07-15T09:00:00.000Z');
58
+ });
59
+
60
+ test('slots: an existing booking and its buffer block the times that would touch them', () => {
61
+ const booking = {id: 'b1', bookableId: 'sam', status: 'confirmed', ...isoSpan(spanOf(CUT, Date.parse('2031-07-15T09:00:00Z')))};
62
+ const [day] = slotsFor({service: CUT, bookables: [SAM], bookings: [booking], from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN});
63
+ const times = day.slots.map(s => s.time);
64
+ // Booked 10:00-11:00 (+15 after, to 11:15). A new 09:00 blocks to 10:15: clash. 11:30 starts after 11:15: fine.
65
+ assert.ok(!times.includes('09:00') && !times.includes('09:30') && !times.includes('10:00') && !times.includes('10:30') && !times.includes('11:00'));
66
+ assert.ok(times.includes('11:30'));
67
+ const cancelled = {...booking, status: 'cancelled'};
68
+ assert.equal(slotsFor({service: CUT, bookables: [SAM], bookings: [cancelled], from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN})[0].slots.length, 12, 'cancelling frees the time');
69
+ assert.equal(slotsFor({service: CUT, bookables: [SAM], bookings: [booking], from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN, ignoreId: 'b1'})[0].slots.length, 12, 'a moved booking does not clash with itself');
70
+ });
71
+
72
+ test('slots: another bookable\'s booking does not block; busy time from the Calendar does', () => {
73
+ const other = {id: 'x', bookableId: 'room', status: 'confirmed', startAt: '2031-07-15T08:00:00Z', endAt: '2031-07-15T09:00:00Z'};
74
+ const busy = {sam: [{startAt: '2031-07-15T13:00:00Z', endAt: '2031-07-15T14:00:00Z'}]};
75
+ const [day] = slotsFor({service: CUT, bookables: [SAM], bookings: [other], busy, from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN});
76
+ const times = day.slots.map(s => s.time);
77
+ assert.ok(times.includes('09:00'));
78
+ assert.ok(!times.includes('14:00') && !times.includes('14:30'), '14:00-15:00 local is busy');
79
+ assert.ok(times.includes('15:00'));
80
+ });
81
+
82
+ test('slots: several providers - a time is offered while any of them is free, with who', () => {
83
+ const [day] = slotsFor({service: CUT, bookables: [SAM, ROOM], bookings: [], from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN});
84
+ assert.deepEqual(day.slots.find(s => s.time === '10:00').bookableIds, ['sam', 'room']);
85
+ assert.deepEqual(day.slots.find(s => s.time === '09:00').bookableIds, ['sam']);
86
+ assert.equal(pickBookable(['sam', 'room'], [{bookableId: 'sam', status: 'confirmed', startAt: '2031-07-15T08:00:00Z'}], '2031-07-15', LDN), 'room', 'the less busy one');
87
+ assert.equal(pickBookable(['sam', 'room'], [], '2031-07-15', LDN), 'sam', 'else the first');
88
+ });
89
+
90
+ test('slots: notice and how far ahead', () => {
91
+ const now = '2031-07-15T08:10:00Z'; // 09:10 in London
92
+ const svc = {...CUT, minNoticeMinutes: 120, maxAdvanceDays: 7};
93
+ const [day] = slotsFor({service: svc, bookables: [SAM], bookings: [], from: '2031-07-15', to: '2031-07-15', now, tz: LDN});
94
+ assert.equal(day.slots[0].time, '11:30', 'two hours from 09:10');
95
+ const later = slotsFor({service: svc, bookables: [SAM], bookings: [], from: '2031-07-29', to: '2031-07-29', now, tz: LDN});
96
+ assert.equal(later[0].slots.length, 0, 'beyond a week');
97
+ assert.deepEqual(bookingRange(svc, now, LDN), {first: '2031-07-15', last: '2031-07-22'});
98
+ });
99
+
100
+ test('slots: the spring-forward night never offers a time that does not exist', () => {
101
+ const night = {id: 'n', hours: {sun: [{from: '00:00', to: '04:00'}]}};
102
+ const [day] = slotsFor({service: {...CUT, bufferAfter: 0}, bookables: [night], bookings: [], from: '2031-03-30', to: '2031-03-30', now: '2031-03-01T00:00:00Z', tz: LDN});
103
+ assert.deepEqual(day.slots.map(s => s.time), ['00:00', '00:30', '02:00', '02:30', '03:00']);
104
+ });
105
+
106
+ test('isFree: overlap at the edge is not a clash', () => {
107
+ const b = [{id: 'a', bookableId: 's', status: 'confirmed', blockStart: '2031-07-15T09:00:00Z', blockEnd: '2031-07-15T10:00:00Z'}];
108
+ const at = (s, e) => ({start: Date.parse(s), end: Date.parse(e), blockStart: Date.parse(s), blockEnd: Date.parse(e)});
109
+ assert.ok(isFree(at('2031-07-15T10:00:00Z', '2031-07-15T11:00:00Z'), 's', b));
110
+ assert.ok(!isFree(at('2031-07-15T09:59:00Z', '2031-07-15T11:00:00Z'), 's', b));
111
+ });
112
+
113
+ function isoSpan(span) {
114
+ return {startAt: new Date(span.start).toISOString(), endAt: new Date(span.end).toISOString(),
115
+ blockStart: new Date(span.blockStart).toISOString(), blockEnd: new Date(span.blockEnd).toISOString()};
116
+ }
117
+
118
+ import {cleanBookable, cleanCustomer, cleanService, slugify} from '../public/lib/clean.js';
119
+
120
+ test('cleaning: bookables, services and customers', () => {
121
+ const b = cleanBookable({name: ' Room 1 ', kind: 'resource', hours: {mon: [{from: '09:00', to: '08:00'}, {from: '10:00', to: '12:00'}]}, daysOff: [{from: '2031-01-02'}, {from: 'x'}]});
122
+ assert.deepEqual(b.errors, {});
123
+ assert.equal(b.data.name, 'Room 1');
124
+ assert.deepEqual(b.data.hours.mon, [{from: '10:00', to: '12:00'}]);
125
+ assert.deepEqual(b.data.daysOff, [{from: '2031-01-02', to: '2031-01-02', label: ''}]);
126
+ assert.deepEqual(cleanBookable({}).data.hours.tue, [{from: '09:00', to: '17:00'}], 'office hours to start with');
127
+ assert.ok(cleanBookable({name: ''}).errors.name);
128
+
129
+ const s = cleanService({name: 'Hair cut & finish', duration: '45', bookables: ['a', 'a', '']});
130
+ assert.equal(s.data.slug, 'hair-cut-finish');
131
+ assert.deepEqual([s.data.duration, s.data.step, s.data.bookables], [45, 45, ['a']]);
132
+ assert.ok(cleanService({name: 'X'}).errors.bookables);
133
+ assert.equal(slugify('Café'), 'cafe');
134
+
135
+ const c = cleanCustomer({name: 'Sam', email: 'SAM@Example.com ', phone: '07700 900123'});
136
+ assert.deepEqual(c, {data: {name: 'Sam', email: 'sam@example.com', phone: '07700 900123', notes: ''}, errors: {}});
137
+ assert.ok(cleanCustomer({name: 'Sam', email: 'nope'}).errors.email);
138
+ assert.ok(cleanCustomer({name: 'Sam', email: 'a@b.co'}, {phoneRequired: true}).errors.phone);
139
+ assert.equal(cleanCustomer({name: 'Sam', email: 'a@b.co', phone: '1'}, {askPhone: false}).data.phone, '');
140
+ });
141
+
142
+ import {PLAIN, holdsOf, placesLeft, whoCanTake} from '../public/lib/slots.js';
143
+
144
+ // ---- Booking Pro's rules, applied by the free engine ---------------------------------
145
+
146
+ const TRAINER = {id: 'tr', hours: {tue: [{from: '09:00', to: '12:00'}]}};
147
+ const COURT1 = {id: 'c1', hours: {tue: [{from: '09:00', to: '12:00'}]}};
148
+ const COURT2 = {id: 'c2', hours: {tue: [{from: '09:00', to: '12:00'}]}};
149
+ const LESSON = {id: 'lesson', duration: 60, step: 60, bufferBefore: 0, bufferAfter: 0, minNoticeMinutes: 0, maxAdvanceDays: 0};
150
+ const at = (time) => atWallClock('2031-07-15', time, LDN).toISOString();
151
+ const held = (id, time, extra = {}) => ({id, status: 'confirmed', serviceId: 'lesson', startAt: at(time), endAt: new Date(Date.parse(at(time)) + 3600000).toISOString(), ...extra});
152
+
153
+ test('companions: a time needs the trainer AND a free court; the booking holds both', () => {
154
+ const rules = {...PLAIN, companions: [[COURT1, COURT2]]};
155
+ const bookings = [held('x', '09:00', {bookableId: 'other', holds: ['other', 'c1']}), held('y', '09:00', {bookableId: 'other2', holds: ['other2', 'c2']})];
156
+ const [day] = slotsFor({service: LESSON, bookables: [TRAINER], bookings, from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN, rules});
157
+ assert.deepEqual(day.slots.map(s => s.time), ['10:00', '11:00'], 'both courts taken at 09:00');
158
+ const span = spanOf(LESSON, Date.parse(at('10:00')));
159
+ assert.deepEqual(whoCanTake({service: LESSON, candidates: [TRAINER], companions: rules.companions, span, bookings, rules}), [{bookableId: 'tr', holds: ['tr', 'c1'], left: 1}]);
160
+ assert.deepEqual(holdsOf({bookableId: 'a'}), ['a']);
161
+ });
162
+
163
+ test('places: a class takes several people at one start, then is full', () => {
164
+ const rules = {...PLAIN, capacity: 3};
165
+ const bookings = [held('a', '09:00', {bookableId: 'tr'}), held('b', '09:00', {bookableId: 'tr'})];
166
+ let [day] = slotsFor({service: LESSON, bookables: [TRAINER], bookings, from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN, rules});
167
+ assert.equal(day.slots.find(s => s.time === '09:00').left, 1);
168
+ assert.equal(day.slots.find(s => s.time === '10:00').left, 3);
169
+ bookings.push(held('c', '09:00', {bookableId: 'tr'}));
170
+ [day] = slotsFor({service: LESSON, bookables: [TRAINER], bookings, from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN, rules});
171
+ assert.ok(!day.slots.some(s => s.time === '09:00'), 'full');
172
+ [day] = slotsFor({service: LESSON, bookables: [TRAINER], bookings, from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN, rules, includeFull: true});
173
+ assert.deepEqual(day.slots.find(s => s.time === '09:00'), {start: at('09:00'), end: day.slots[0].end, time: '09:00', bookableIds: [], left: 0, full: true, fullIds: ['tr']});
174
+ // Another service overlapping the class is still a clash.
175
+ const other = [held('d', '10:00', {bookableId: 'tr', serviceId: 'massage'})];
176
+ [day] = slotsFor({service: LESSON, bookables: [TRAINER], bookings: other, from: '2031-07-15', to: '2031-07-15', now: NOW, tz: LDN, rules});
177
+ assert.ok(!day.slots.some(s => s.time === '10:00'));
178
+ assert.equal(pickBookable(['sam', 'tr'], [held('a', '09:00', {bookableId: 'tr'})], '2031-07-15', LDN, {serviceId: 'lesson', start: Date.parse(at('09:00'))}), 'tr', 'join the class that runs');
179
+ });
180
+
181
+ test('a day\'s limit counts sessions; joining a running class is never over it', () => {
182
+ const dayOf = (ms) => wallClock(ms, LDN).date;
183
+ const bookings = [held('a', '09:00', {bookableId: 'tr'})];
184
+ const span10 = spanOf(LESSON, Date.parse(at('10:00')));
185
+ const span9 = spanOf(LESSON, Date.parse(at('09:00')));
186
+ assert.equal(placesLeft(span10, 'tr', bookings, [], '', {serviceId: 'lesson', capacity: 1, maxPerDay: 1, dayOf}), 0);
187
+ assert.equal(placesLeft(span9, 'tr', bookings, [], '', {serviceId: 'lesson', capacity: 2, maxPerDay: 1, dayOf}), 1);
188
+ assert.equal(placesLeft(span10, 'tr', bookings, [], '', {serviceId: 'lesson', capacity: 1, maxPerDay: 2, dayOf}), 1);
189
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@f79220a",
3
+ "source": "dcms-marketplace@4cdfbe1",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.4",
@@ -72,6 +72,49 @@
72
72
  "tests/public.test.js": "a6fcdc4ede242aa050ffe5a52f970d6bec1f4a70ae78967eb8a7bdeec73df19d"
73
73
  }
74
74
  },
75
+ "booking": {
76
+ "version": "1.4.0",
77
+ "files": {
78
+ "CLAUDE.md": "8e96d41dcbba3adcebf8456a6638e266f559d6fbfb4ba1406645a82cb40ce146",
79
+ "admin/css/index.css": "4f7731921c0c5038cb1566a6ab2d43ffc35bf9b161e58f87a0cff423e81b47fa",
80
+ "admin/lib/details.js": "8b37536bfa9ccfcc5265cc957ba8266d3f5f777133e7c0946ae49540f1c80e3c",
81
+ "admin/lib/diary-layout.js": "d818c79001fb9a9ecc15c96239ba961b0a1d98b9e8fce903eefb57d85adf2a73",
82
+ "admin/lib/extension-sections.js": "0588ad47660e511b0dd8077bb9c3999c775d97597333a75e87ee12d915bbf00b",
83
+ "admin/lib/kit.js": "661e9c407bf398694dfa08105590065a6352594d526f3c09c31b180c840045bf",
84
+ "admin/templates/booking.html": "20b9a3bfe3e5d5bbee613f29be99551b12790fb38f80d119585360cc38e9ec57",
85
+ "admin/views/booking.js": "4eddf6c0b60dbbba4c6e35352df247a3d34b66ee1d43c51fe2d53f894707348f",
86
+ "admin/views/bookings.js": "bf17db82ed4d2f5c29ac924dd60dc0b84c8bd9c8ce32da6cbb011527699a56a6",
87
+ "admin/views/diary.js": "9e8976e67c2232d353b3d9a1ef0d0503cc4d06b710d54d0cba84fb5b796fdfe9",
88
+ "admin/views/panels.js": "9d1c45e56d2a7a8caa12e9af29545c808ffb88da3ea48397d55aae19e802ec69",
89
+ "admin/views/people.js": "58cb9cc550b48c959a5572a4d25650ea2b32b658c9d0bf4c244e4abe0277c211",
90
+ "admin/views/services.js": "122326d222ed16dc8ec903fef6f62f6a7638c31d73c4612b7a8c2ced38358068",
91
+ "admin/views/settings.js": "299297b47a6509986276a859807a33ea225681d28e0e9cd06cdf36b0f6ec0c11",
92
+ "collections/booking-bookables.json": "9f1f305eceac3d880413d5477a631e48c1fed7ff389c434f34651f4082ca1a1c",
93
+ "collections/booking-bookings.json": "fa0b15cc81b4a2114734a0bc8adc4b4d25049d16cf62549b83634cdbeb543b52",
94
+ "collections/booking-services.json": "cecb500a9215a0eba894e6b4c9ae78f5fbeb209830d07d47050c56c75143f66a",
95
+ "config.js": "5565fd627481cf809322257cc300a7cbb7ccbf6cae076b4cf8a7453fcf554bd3",
96
+ "docs/guide.md": "545b050500a09ffdf7bc620e01f0b63bf59e92dfe855ba3201419b0bcc92a1fc",
97
+ "inject/body.html": "ab55ce945eec2302eb0c836a4626cffa5e091d050c6a59c6f2af332b05570777",
98
+ "lib/call.js": "7dfa0ae60be87d8f7f26b4360d5c1144322abe66eb61b233ed812e35bb8e59bd",
99
+ "lib/extensions.js": "8cec28ff8685daad8083b23f034f1c3bf82fd2322943dd19d46570ccdc311105",
100
+ "lib/mail.js": "cad8f409df62d6e42f0f4024ec364bbe018a62b842fa3f7c38786fd4e7f92408",
101
+ "lib/store.js": "270161f907c68de39d32758931e86c290582524af4650e722c2c3be209acc875",
102
+ "plugin.js": "963e8190dbcaec71915d73402648899bb38135b25ec869a659d0938d03868682",
103
+ "plugin.json": "0ef595cd1925b41194821f97d0e0e0fca5edb20a98a0f97a8a0efd52e8c50c16",
104
+ "plugin.public.js": "cd607de84ba7cffe384aa40a1a5976277896bf3973baa6415a01a1a4c9e3aca1",
105
+ "public/booking.css": "8b88989fdd04e50efc1688a2f32fe2e422299d7d13b81bb8fce0cc162fa99df6",
106
+ "public/booking.js": "7dfc3d205696967013886567f67a867443c2e370bac29019d69911d695fdbade",
107
+ "public/lib/clean.js": "d591be9ea8462705b7bc8806670027fdd4d6d4764777da08e91a010d27ec1651",
108
+ "public/lib/page-format.js": "a38454283c2b7256b38ea48578f6df2695a623e0fe2c5d80aae9952e7034a93d",
109
+ "public/lib/slots.js": "6472e530f3f521e7a08625ed2ee86802a9065cbf2f97c2c7601229f5f032dc07",
110
+ "public/lib/time.js": "48e13a23e3d7a4916e2097e8f87c03a87d37b71c4ef037a5a16143c6a4185c41",
111
+ "templates/booking.html": "a4995de46dfdab7e692b56f21347f29c76320cf3b0477889dc3c5d14e0b6dcd7",
112
+ "tests/admin-diary.test.js": "4e34b447d62b41272ceec49bad9d491ae00dd04f4760d8a7a3046be1912f4abe",
113
+ "tests/api.test.js": "57ce73f48776e15454794405bae5b9674baeda46a96e4a11d0e253c6a977a764",
114
+ "tests/public.test.js": "0b7b626d60df5a3d73d7c0077b73f59b62a9fded98ce84a8d93cd32cd0333159",
115
+ "tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
116
+ }
117
+ },
75
118
  "feedback": {
76
119
  "version": "1.1.2",
77
120
  "files": {
@@ -11,6 +11,12 @@
11
11
  *
12
12
  * GET /api/related/providers?kind=contact who can add to a contact: [{id, label, icon, actions}]
13
13
  * GET /api/related?kind=contact&id=<id> {providers: [{id, label, icon, items, error?}]}
14
+ *
15
+ * An action with a `permission` is left out for a viewer whose roles lack it.
16
+ * `message` (0.103, an email in the mail reader) has actions only: no Tool of
17
+ * its own to gate on, no record to check, so no GET /api/related for it.
18
+ *
19
+ * GET /api/related/providers?kind=message [{id, label, icon, actions}] ({email}, {name}, {subject} in urls)
14
20
  */
15
21
  import {authenticate as defaultAuthenticate} from '../../middleware/auth.js';
16
22
  import {getEntry} from '../../services/collections.js';
@@ -45,20 +51,29 @@ export async function relatedRoutes(fastify, opts = {}) {
45
51
  return {id: request.user?.id ?? request.user?.sub ?? null, role: request.user?.role ?? null, roles};
46
52
  };
47
53
  // A kind whose Tool is switched off has nothing to relate to (403, as for a role without it).
48
- const mayRead = (user, kind) => isToolEnabled(KINDS[kind].tool)
49
- && user.roles.some(r => getPermissionsFor(KINDS[kind].resource, 'read').includes(r));
54
+ const mayRead = (user, kind) => !KINDS[kind] || (isToolEnabled(KINDS[kind].tool)
55
+ && user.roles.some(r => getPermissionsFor(KINDS[kind].resource, 'read').includes(r)));
56
+ const allowed = (user, action) => !action.permission
57
+ || user.roles.some(r => getPermissionsFor(action.permission.resource, action.permission.action).includes(r));
50
58
 
51
59
  fastify.get('/providers', {preHandler: [authenticate]}, async (request, reply) => {
52
60
  const kind = String(request.query?.kind ?? '');
53
61
  if (!RELATED_KINDS.includes(kind)) return reply.code(400).send({error: `kind must be one of ${RELATED_KINDS.join(', ')}`});
54
- if (!mayRead(viewer(request), kind)) return reply.code(403).send({error: 'Forbidden'});
55
- return getRelatedProviders(kind).map(({id, label, icon, actions}) => ({id, label, icon, actions}));
62
+ const user = viewer(request);
63
+ if (!mayRead(user, kind)) return reply.code(403).send({error: 'Forbidden'});
64
+ return getRelatedProviders(kind)
65
+ .map(({id, label, icon, actions}) => ({
66
+ id, label, icon,
67
+ actions: actions.filter(a => allowed(user, a)).map(({label, icon, url}) => ({label, icon, url}))
68
+ }))
69
+ // A provider with only actions, none of them this viewer's, has nothing to offer.
70
+ .filter(p => kind !== 'message' || p.actions.length);
56
71
  });
57
72
 
58
73
  fastify.get('/', {preHandler: [authenticate]}, async (request, reply) => {
59
74
  const kind = String(request.query?.kind ?? '');
60
75
  const id = String(request.query?.id ?? '');
61
- if (!RELATED_KINDS.includes(kind) || !id) return reply.code(400).send({error: 'kind and id are required'});
76
+ if (!RELATED_KINDS.includes(kind) || !KINDS[kind] || !id) return reply.code(400).send({error: 'kind and id are required'});
62
77
  const user = viewer(request);
63
78
  if (!mayRead(user, kind)) return reply.code(403).send({error: 'Forbidden'});
64
79
 
package/server/server.js CHANGED
@@ -593,8 +593,8 @@ await app.register(relatedRoutes, {prefix: '/api/related'});
593
593
  // site would simply never see Notes or Todo. Registered items are placed at
594
594
  // render time on every site (placeTools() in admin/js/lib/sidebar-grouping.js):
595
595
  // in the Tools folder by default, or wherever an admin has moved them.
596
- // `group: 'Core'` gathers them in a Core sub-folder (Tools -> Core -> Notes),
597
- // apart from the marketplace plugins' Tools, which sit directly in Tools.
596
+ // The registry supplies purpose-based groups shared with marketplace Tools;
597
+ // an admin's saved placement always takes precedence.
598
598
  //
599
599
  // `permission` is what hides each one from a role that cannot use it; the
600
600
  // renderer filters on it.
@@ -610,7 +610,7 @@ const coreToolItems = [
610
610
  {text: 'SEO', url: '#/seo', icon: 'search', permission: 'seo', countUrl: '/api/seo/count'}
611
611
  ];
612
612
  for (const item of coreToolItems) {
613
- if (isToolEnabled(item.permission)) registerSidebarItem({group: 'Core', item: {source: 'Built in', ...item}});
613
+ if (isToolEnabled(item.permission)) registerSidebarItem({item: {source: 'Built in', ...item}});
614
614
  }
615
615
  // Todo's to-dos on the calendar (registered when routes/api/todo.js loads).
616
616
  if (!isToolEnabled('todo')) {
@@ -70,6 +70,19 @@ export async function registerMenuLocation(slot, def) {
70
70
 
71
71
  const _registeredSidebarItems = [];
72
72
 
73
+ /** Purpose-based defaults shared by built-in and marketplace Tools. Match URLs,
74
+ * so renamed labels and Pro replacements retain the same starting place.
75
+ * Unknown plugins remain directly in Tools until they declare their own group.
76
+ */
77
+ const TOOL_GROUPS = new Map(Object.entries({
78
+ Productivity: ['#/notes', '#/todo', '#/plugins/calendar', '#/plugins/waypoint-pro'],
79
+ Communication: ['#/contacts', '#/plugins/contacts-pro', '#/plugins/mail-reader', '#/plugins/email-pro', '#/plugins/messages'],
80
+ Marketing: ['#/analytics', '#/seo', '#/plugins/analytics-pro/dashboard', '#/plugins/social', '#/plugins/feedback', '#/plugins/surveys'],
81
+ Publishing: ['#/plugins/blog', '#/plugins/blog-pro', '#/plugins/docs', '#/plugins/theme-roller'],
82
+ Business: ['#/plugins/shopping-cart', '#/plugins/booking', '#/plugins/invoice', '#/job-board', '#/plugins/garage'],
83
+ Maintenance: ['#/plugins/security', '#/plugins/security-pro', '#/plugins/security-pro/my-2fa', '#/plugins/backup-pro', '#/plugins/data-transfer']
84
+ }).flatMap(([group, urls]) => urls.map(url => [url, group])));
85
+
73
86
  /**
74
87
  * Register a Tool's sidebar entry. Entries are placed into the rendered admin
75
88
  * sidebar at render time - they are NOT persisted into menu JSON - so plugins
@@ -93,10 +106,12 @@ export function registerSidebarItem(opts) {
93
106
  throw new Error('registerSidebarItem: opts.item.text is required');
94
107
  }
95
108
  const {folder: itemFolder, group: itemGroup, ...item} = opts.item;
96
- const group = [opts.group, itemGroup].find(g => typeof g === 'string' && g.trim());
109
+ const folder = opts.folder || itemFolder || DEFAULT_TOOL_FOLDER;
110
+ const defaultGroup = folder === DEFAULT_TOOL_FOLDER && !opts.parent ? TOOL_GROUPS.get(item.url) : undefined;
111
+ const group = [opts.group, itemGroup, defaultGroup].find(g => typeof g === 'string' && g.trim());
97
112
  _registeredSidebarItems.push({
98
113
  parent: opts.parent || null,
99
- folder: opts.folder || itemFolder || DEFAULT_TOOL_FOLDER,
114
+ folder,
100
115
  ...(group && {group: group.trim()}),
101
116
  item
102
117
  });
@@ -270,7 +285,10 @@ export function getCalendarSources() {
270
285
  // provider per kind; GET /api/related asks each one for what relates to one
271
286
  // record, after core has checked the viewer may see that record at all.
272
287
 
273
- export const RELATED_KINDS = ['contact', 'note'];
288
+ // 'message' (0.103): an email open in the mail reader. It has no record id core
289
+ // can check, so only `actions` apply - `{email}`, `{name}` and `{subject}` in a
290
+ // url become the sender's address and name and the subject - and there is no list.
291
+ export const RELATED_KINDS = ['contact', 'note', 'message'];
274
292
  const _relatedProviders = new Map(); // kind -> Map(id -> provider)
275
293
 
276
294
  /**
@@ -279,27 +297,36 @@ const _relatedProviders = new Map(); // kind -> Map(id -> provider)
279
297
  * `list({id, user})` resolves to `[{id, title, when?, startAt?, allDay?, meta?, url?, past?}]`
280
298
  * - with `startAt` and no `when`, the admin writes the time in the reader's zone.
281
299
  * `actions` are shown in the record's menu, `{id}` in a url replaced by the
282
- * record's id: `[{label, icon?, url}]`.
300
+ * record's id: `[{label, icon?, url, permission?}]`. `permission`
301
+ * (`{resource, action}`) shows the action only to roles that hold it.
283
302
  *
284
- * @param {'contact'|'note'} kind
303
+ * @param {'contact'|'note'|'message'} kind
285
304
  * @param {{id: string, label: string, icon?: string, list: Function, actions?: object[], owner?: string}} provider
286
305
  * @returns {boolean}
287
306
  */
288
307
  export function registerRelatedItems(kind, provider = {}) {
289
308
  if (!RELATED_KINDS.includes(kind)) return false;
290
309
  const {id, list} = provider;
291
- if (typeof id !== 'string' || !id || typeof list !== 'function') return false;
310
+ if (typeof id !== 'string' || !id) return false;
311
+ if (kind !== 'message' && typeof list !== 'function') return false;
292
312
  if (!_relatedProviders.has(kind)) _relatedProviders.set(kind, new Map());
293
313
  const actions = (Array.isArray(provider.actions) ? provider.actions : [])
294
314
  .filter(a => a && typeof a.label === 'string' && typeof a.url === 'string' && a.url.startsWith('#/'))
295
- .map(a => ({label: a.label, icon: typeof a.icon === 'string' ? a.icon : 'arrow-right', url: a.url}));
315
+ .map(a => ({
316
+ label: a.label,
317
+ icon: typeof a.icon === 'string' ? a.icon : 'arrow-right',
318
+ url: a.url,
319
+ ...(a.permission && typeof a.permission.resource === 'string'
320
+ ? {permission: {resource: a.permission.resource, action: typeof a.permission.action === 'string' ? a.permission.action : 'read'}}
321
+ : {})
322
+ }));
296
323
  _relatedProviders.get(kind).set(id, {
297
324
  id,
298
325
  label: typeof provider.label === 'string' && provider.label.trim() ? provider.label.trim() : id,
299
326
  icon: typeof provider.icon === 'string' ? provider.icon : 'link',
300
327
  owner: typeof provider.owner === 'string' ? provider.owner : 'core',
301
328
  actions,
302
- list
329
+ list: typeof list === 'function' ? list : () => []
303
330
  });
304
331
  return true;
305
332
  }