domma-cms 0.102.1 → 0.103.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 +1 -1
- package/plugins/_lib/admin/mail/message-actions.js +56 -0
- package/plugins/_lib/admin/mail/reader-view.js +30 -1
- package/plugins/_lib/call/ice.js +58 -0
- package/plugins/_lib/call/rooms.js +232 -0
- package/plugins/_lib/call/routes.js +88 -0
- package/plugins/_lib/public/call/call.css +1 -0
- package/plugins/_lib/public/call/call.js +468 -0
- package/plugins/booking/CLAUDE.md +123 -0
- package/plugins/booking/admin/css/index.css +192 -0
- package/plugins/booking/admin/lib/details.js +58 -0
- package/plugins/booking/admin/lib/diary-layout.js +140 -0
- package/plugins/booking/admin/lib/extension-sections.js +160 -0
- package/plugins/booking/admin/lib/kit.js +249 -0
- package/plugins/booking/admin/templates/booking.html +1 -0
- package/plugins/booking/admin/views/booking.js +177 -0
- package/plugins/booking/admin/views/bookings.js +127 -0
- package/plugins/booking/admin/views/diary.js +374 -0
- package/plugins/booking/admin/views/panels.js +516 -0
- package/plugins/booking/admin/views/people.js +281 -0
- package/plugins/booking/admin/views/services.js +222 -0
- package/plugins/booking/admin/views/settings.js +203 -0
- package/plugins/booking/collections/booking-bookables.json +17 -0
- package/plugins/booking/collections/booking-bookings.json +18 -0
- package/plugins/booking/collections/booking-services.json +17 -0
- package/plugins/booking/config.js +36 -0
- package/plugins/booking/docs/guide.md +69 -0
- package/plugins/booking/inject/body.html +1 -0
- package/plugins/booking/lib/call.js +58 -0
- package/plugins/booking/lib/extensions.js +150 -0
- package/plugins/booking/lib/mail.js +113 -0
- package/plugins/booking/lib/store.js +384 -0
- package/plugins/booking/plugin.js +760 -0
- package/plugins/booking/plugin.json +80 -0
- package/plugins/booking/plugin.public.js +157 -0
- package/plugins/booking/public/booking.css +159 -0
- package/plugins/booking/public/booking.js +808 -0
- package/plugins/booking/public/lib/clean.js +141 -0
- package/plugins/booking/public/lib/page-format.js +121 -0
- package/plugins/booking/public/lib/slots.js +247 -0
- package/plugins/booking/public/lib/time.js +131 -0
- package/plugins/booking/templates/booking.html +5 -0
- package/plugins/booking/tests/admin-diary.test.js +73 -0
- package/plugins/booking/tests/api.test.js +356 -0
- package/plugins/booking/tests/public.test.js +71 -0
- package/plugins/booking/tests/pure.test.js +189 -0
- package/plugins/free-tier.lock.json +44 -1
- package/server/routes/api/related.js +20 -5
- package/server/server.js +3 -3
- 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@
|
|
3
|
+
"source": "dcms-marketplace@7098cbc",
|
|
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.3.1",
|
|
77
|
+
"files": {
|
|
78
|
+
"CLAUDE.md": "66c75435a64162573e9dd20bd6c28118f4b5c5505d56cf9ad73891e1e91f79fe",
|
|
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": "f1935abbfebb5c5054ac1691e92d5af622db50ea1b17251d99089a765e4f6997",
|
|
84
|
+
"admin/templates/booking.html": "20b9a3bfe3e5d5bbee613f29be99551b12790fb38f80d119585360cc38e9ec57",
|
|
85
|
+
"admin/views/booking.js": "562f7212d4f6074e6154afd2764c374eba8c1aed46dfc6c876c9fb08d04da938",
|
|
86
|
+
"admin/views/bookings.js": "bf17db82ed4d2f5c29ac924dd60dc0b84c8bd9c8ce32da6cbb011527699a56a6",
|
|
87
|
+
"admin/views/diary.js": "9e8976e67c2232d353b3d9a1ef0d0503cc4d06b710d54d0cba84fb5b796fdfe9",
|
|
88
|
+
"admin/views/panels.js": "7cc00b24f780e6364be7c5cb929d0a6e71ee820b207b4be1f5a87cf07ea1b1ea",
|
|
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": "885a4bb9ffde34309a9d795232c376104e550f84ea0ea997aaba215c88cb5bcc",
|
|
97
|
+
"inject/body.html": "ab55ce945eec2302eb0c836a4626cffa5e091d050c6a59c6f2af332b05570777",
|
|
98
|
+
"lib/call.js": "ce14cc92ac150f25893b60e7d5a20b5627067efbf4b776eae8ccfa534e3ab061",
|
|
99
|
+
"lib/extensions.js": "ccb8a1c451f111ccee8b7f71c92d5ef54ee7771ec35cbfefa5632bddee906457",
|
|
100
|
+
"lib/mail.js": "cad8f409df62d6e42f0f4024ec364bbe018a62b842fa3f7c38786fd4e7f92408",
|
|
101
|
+
"lib/store.js": "1b8d625c16fc4fd1dd233e9a5157de6e5b25ffb6f6fe05fc49749e4008e3ba82",
|
|
102
|
+
"plugin.js": "ae3ea8f5a63a1af7818064e9f9b83fbb16502266510d24216f3e65c93919a5f8",
|
|
103
|
+
"plugin.json": "2f973abbdcf0b1b148e89295b1b6469cc48b9d5585e5e8ffc2b7099f3ff4c018",
|
|
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": "f97b3b82370dd7eb3001fea7f4f806fa56f25e77605f1c6f843fe89244f9e86d",
|
|
114
|
+
"tests/public.test.js": "0d14c2fe47877ccb7aeae45210cec977c78973be4d2edc323a9315bb985d1f8b",
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
597
|
-
//
|
|
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({
|
|
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')) {
|
package/server/services/hooks.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 => ({
|
|
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
|
}
|