@lilaquadrat/studio 13.0.0 → 13.1.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 (121) hide show
  1. package/lib/helpers.d.ts +2 -1
  2. package/lib/helpers.js +2 -1
  3. package/lib/helpers.js.map +1 -1
  4. package/lib/models.d.ts +3 -1
  5. package/lib/models.js +3 -1
  6. package/lib/models.js.map +1 -1
  7. package/lib/services.d.ts +4 -1
  8. package/lib/services.js +4 -1
  9. package/lib/services.js.map +1 -1
  10. package/lib/src/aggegations/genericData.aggregation.js +1 -1
  11. package/lib/src/aggegations/genericData.aggregation.js.map +1 -1
  12. package/lib/src/helpers/escapeRegExp.d.ts +2 -0
  13. package/lib/src/helpers/escapeRegExp.js +5 -0
  14. package/lib/src/helpers/escapeRegExp.js.map +1 -0
  15. package/lib/src/helpers/shiftPlanningSchemas.d.ts +6 -0
  16. package/lib/src/helpers/shiftPlanningSchemas.js +1 -0
  17. package/lib/src/helpers/shiftPlanningSchemas.js.map +1 -1
  18. package/lib/src/interfaces/CategoryBookedTime.d.ts +5 -0
  19. package/lib/src/interfaces/CategoryBookedTime.js +2 -0
  20. package/lib/src/interfaces/CategoryBookedTime.js.map +1 -0
  21. package/lib/src/interfaces/TrackerDailyLimitGroup.d.ts +7 -0
  22. package/lib/src/interfaces/TrackerDailyLimitGroup.js +2 -0
  23. package/lib/src/interfaces/TrackerDailyLimitGroup.js.map +1 -0
  24. package/lib/src/interfaces/TrackerDailyLimitInput.d.ts +8 -0
  25. package/lib/src/interfaces/TrackerDailyLimitInput.js +2 -0
  26. package/lib/src/interfaces/TrackerDailyLimitInput.js.map +1 -0
  27. package/lib/src/interfaces/TrackerStatisticsBucket.d.ts +13 -0
  28. package/lib/src/interfaces/TrackerStatisticsBucket.js +2 -0
  29. package/lib/src/interfaces/TrackerStatisticsBucket.js.map +1 -0
  30. package/lib/src/interfaces/TrackerStatisticsRange.d.ts +6 -0
  31. package/lib/src/interfaces/TrackerStatisticsRange.js +2 -0
  32. package/lib/src/interfaces/TrackerStatisticsRange.js.map +1 -0
  33. package/lib/src/interfaces/TrackerTimeSum.d.ts +4 -0
  34. package/lib/src/interfaces/TrackerTimeSum.js +2 -0
  35. package/lib/src/interfaces/TrackerTimeSum.js.map +1 -0
  36. package/lib/src/models/editor.model.js +2 -0
  37. package/lib/src/models/editor.model.js.map +1 -1
  38. package/lib/src/models/permissions.model.js +10 -0
  39. package/lib/src/models/permissions.model.js.map +1 -1
  40. package/lib/src/models/shift.model.js +1 -1
  41. package/lib/src/models/shift.model.js.map +1 -1
  42. package/lib/src/models/storage.model.js +2 -1
  43. package/lib/src/models/storage.model.js.map +1 -1
  44. package/lib/src/models/timeTrackingProject.model.d.ts +4 -0
  45. package/lib/src/models/timeTrackingProject.model.js +57 -0
  46. package/lib/src/models/timeTrackingProject.model.js.map +1 -0
  47. package/lib/src/models/timeTrackingProjectAllocation.model.d.ts +4 -0
  48. package/lib/src/models/timeTrackingProjectAllocation.model.js +38 -0
  49. package/lib/src/models/timeTrackingProjectAllocation.model.js.map +1 -0
  50. package/lib/src/models/tracker.model.js +18 -2
  51. package/lib/src/models/tracker.model.js.map +1 -1
  52. package/lib/src/services/lists.service.js +33 -0
  53. package/lib/src/services/lists.service.js.map +1 -1
  54. package/lib/src/services/shiftPlans.service.d.ts +1 -1
  55. package/lib/src/services/shiftPlans.service.js +10 -4
  56. package/lib/src/services/shiftPlans.service.js.map +1 -1
  57. package/lib/src/services/shifts.service.d.ts +3 -0
  58. package/lib/src/services/shifts.service.js +25 -6
  59. package/lib/src/services/shifts.service.js.map +1 -1
  60. package/lib/src/services/timeTrackingProjectAllocations.service.d.ts +20 -0
  61. package/lib/src/services/timeTrackingProjectAllocations.service.js +85 -0
  62. package/lib/src/services/timeTrackingProjectAllocations.service.js.map +1 -0
  63. package/lib/src/services/timeTrackingProjects.service.d.ts +18 -0
  64. package/lib/src/services/timeTrackingProjects.service.js +78 -0
  65. package/lib/src/services/timeTrackingProjects.service.js.map +1 -0
  66. package/lib/src/services/tracker.service.d.ts +150 -0
  67. package/lib/src/services/tracker.service.js +732 -0
  68. package/lib/src/services/tracker.service.js.map +1 -0
  69. package/lib/tests/listsSingle.spec.d.ts +1 -0
  70. package/lib/tests/listsSingle.spec.js +56 -0
  71. package/lib/tests/listsSingle.spec.js.map +1 -0
  72. package/lib/tests/shiftPlanning.spec.js +63 -11
  73. package/lib/tests/shiftPlanning.spec.js.map +1 -1
  74. package/lib/tests/shiftPlansList.spec.js +1 -1
  75. package/lib/tests/shiftPlansList.spec.js.map +1 -1
  76. package/lib/tests/storageModel.spec.d.ts +1 -0
  77. package/lib/tests/storageModel.spec.js +12 -0
  78. package/lib/tests/storageModel.spec.js.map +1 -0
  79. package/lib/tests/timeTrackingProjectAllocationModel.spec.d.ts +1 -0
  80. package/lib/tests/timeTrackingProjectAllocationModel.spec.js +54 -0
  81. package/lib/tests/timeTrackingProjectAllocationModel.spec.js.map +1 -0
  82. package/lib/tests/timeTrackingProjectAllocationsService.spec.d.ts +1 -0
  83. package/lib/tests/timeTrackingProjectAllocationsService.spec.js +153 -0
  84. package/lib/tests/timeTrackingProjectAllocationsService.spec.js.map +1 -0
  85. package/lib/tests/timeTrackingProjectModel.spec.d.ts +1 -0
  86. package/lib/tests/timeTrackingProjectModel.spec.js +151 -0
  87. package/lib/tests/timeTrackingProjectModel.spec.js.map +1 -0
  88. package/lib/tests/timeTrackingProjectsService.spec.d.ts +1 -0
  89. package/lib/tests/timeTrackingProjectsService.spec.js +256 -0
  90. package/lib/tests/timeTrackingProjectsService.spec.js.map +1 -0
  91. package/lib/tests/trackerAllUsers.spec.d.ts +1 -0
  92. package/lib/tests/trackerAllUsers.spec.js +46 -0
  93. package/lib/tests/trackerAllUsers.spec.js.map +1 -0
  94. package/lib/tests/trackerAllocationUsage.spec.d.ts +1 -0
  95. package/lib/tests/trackerAllocationUsage.spec.js +36 -0
  96. package/lib/tests/trackerAllocationUsage.spec.js.map +1 -0
  97. package/lib/tests/trackerBookings.spec.d.ts +1 -0
  98. package/lib/tests/trackerBookings.spec.js +67 -0
  99. package/lib/tests/trackerBookings.spec.js.map +1 -0
  100. package/lib/tests/trackerCategories.spec.d.ts +1 -0
  101. package/lib/tests/trackerCategories.spec.js +40 -0
  102. package/lib/tests/trackerCategories.spec.js.map +1 -0
  103. package/lib/tests/trackerDailyLimit.spec.d.ts +1 -0
  104. package/lib/tests/trackerDailyLimit.spec.js +84 -0
  105. package/lib/tests/trackerDailyLimit.spec.js.map +1 -0
  106. package/lib/tests/trackerModel.spec.d.ts +1 -0
  107. package/lib/tests/trackerModel.spec.js +73 -0
  108. package/lib/tests/trackerModel.spec.js.map +1 -0
  109. package/lib/tests/trackerShiftAssignment.spec.d.ts +1 -0
  110. package/lib/tests/trackerShiftAssignment.spec.js +125 -0
  111. package/lib/tests/trackerShiftAssignment.spec.js.map +1 -0
  112. package/lib/tests/trackerShiftBookings.spec.d.ts +1 -0
  113. package/lib/tests/trackerShiftBookings.spec.js +58 -0
  114. package/lib/tests/trackerShiftBookings.spec.js.map +1 -0
  115. package/lib/tests/trackerShiftPlans.spec.d.ts +1 -0
  116. package/lib/tests/trackerShiftPlans.spec.js +251 -0
  117. package/lib/tests/trackerShiftPlans.spec.js.map +1 -0
  118. package/lib/tests/trackerWholePlanStatistics.spec.d.ts +1 -0
  119. package/lib/tests/trackerWholePlanStatistics.spec.js +41 -0
  120. package/lib/tests/trackerWholePlanStatistics.spec.js.map +1 -0
  121. package/package.json +2 -2
@@ -0,0 +1,732 @@
1
+ import dayjs from 'dayjs';
2
+ import { ObjectId } from 'mongodb';
3
+ import Immutable from '../Immutable.class.js';
4
+ import ImmutableError from '../classes/errors/Immutable.error.js';
5
+ import handleError from '../functions/handleError.js';
6
+ import respondCode from '../functions/respondCode.js';
7
+ import escapeRegExp from '../helpers/escapeRegExp.js';
8
+ import lookupUserAggregation from '../aggegations/user.aggregation.js';
9
+ import CustomersModel from '../models/customers.model.js';
10
+ import MeModel from '../models/me.model.js';
11
+ import ShiftModel from '../models/shift.model.js';
12
+ import TimeTrackingProjectAllocationModel from '../models/timeTrackingProjectAllocation.model.js';
13
+ import TrackerModel from '../models/tracker.model.js';
14
+ import { MeService } from './me.service.js';
15
+ import TimeTrackingProjectsService from './timeTrackingProjects.service.js';
16
+ const MILLISECONDS_PER_HOUR = 3600000;
17
+ const DEFAULT_MAXIMUM_HOURS_PER_DAY = 24;
18
+ /** the `user` query value that lists every person's entries; `filterAdmin` removes `user` for anyone without the admin scope */
19
+ export const TRACKER_ALL_USERS = 'all';
20
+ /** a plan view shows a month or a week, so a longer range is a mistake and would load an unbounded list */
21
+ const MAXIMUM_BOOKING_RANGE_DAYS = 62;
22
+ const MAXIMUM_BOOKINGS = 2000;
23
+ export class TrackerService extends Immutable {
24
+ constructor() {
25
+ super(...arguments);
26
+ this.model = TrackerModel;
27
+ }
28
+ /** writes are pinned to the company and project, so an id from another tenant never matches */
29
+ scopeFilter(id, options) {
30
+ return { _id: id, company: options.company, project: options.project };
31
+ }
32
+ /** the stored entry of this company and project; it carries the owner and plan, which a change payload does not */
33
+ scopedBooking(id, options) {
34
+ return this.model.db.findOne(this.scopeFilter(id, options));
35
+ }
36
+ /**
37
+ * Turns the request entries into bookings of the caller and runs every guard before anything is stored.
38
+ * Only an admin books for another person, so everybody else loses the `customer` the body names.
39
+ */
40
+ async prepareBookings(input, options) {
41
+ const entries = input.map((single) => ({
42
+ ...single,
43
+ startTime: single.startTime ? new Date(single.startTime) : undefined,
44
+ endTime: single.endTime ? new Date(single.endTime) : undefined,
45
+ company: options.company,
46
+ project: options.project,
47
+ user: options.user,
48
+ }));
49
+ const admin = await this.bookingAdmin(entries, options);
50
+ const bookings = admin ? entries : entries.map((entry) => this.withoutCustomer(entry));
51
+ await this.assertShiftAssignment(bookings, options, admin);
52
+ await this.assertPlanGrouping(bookings, options);
53
+ await this.assertDailyLimit(bookings, options);
54
+ return bookings;
55
+ }
56
+ async createBooking(input, options) {
57
+ const [booking] = await this.prepareBookings([input], options);
58
+ return this.create(booking, options.user, options.app);
59
+ }
60
+ async createBookings(input, options) {
61
+ const bookings = await this.prepareBookings(input || [], options);
62
+ return this.createMultiple(bookings, options.user, options.app);
63
+ }
64
+ /** the change payload with real dates, without the keys the model keeps as booked */
65
+ changeOf(input) {
66
+ const { startTime, endTime, ...fields } = input;
67
+ const change = { ...fields };
68
+ // immutable() drops them too, removing them here keeps the checks on the stored person and plan
69
+ this.model.immutable.forEach((key) => delete change[key]);
70
+ if (startTime)
71
+ change.startTime = new Date(startTime);
72
+ if (endTime)
73
+ change.endTime = new Date(endTime);
74
+ if (startTime && endTime)
75
+ change.currentTime = new Date(endTime).getTime() - new Date(startTime).getTime();
76
+ return change;
77
+ }
78
+ /**
79
+ * Changes a booking of this company and project. Access is decided by `ownBookingOrAdmin` before.
80
+ * The shift assignment is only checked when the shift or the person changes, so entries whose
81
+ * assignment was removed later can still get their time or description corrected.
82
+ */
83
+ async changeBooking(id, input, options) {
84
+ const stored = await this.scopedBooking(id, options);
85
+ if (!stored)
86
+ throw new ImmutableError('DOCUMENT_FOR_UPDATE_MISSING');
87
+ const requested = this.changeOf(input);
88
+ const admin = await this.bookingAdmin([requested], options);
89
+ const change = admin ? requested : this.withoutCustomer(requested);
90
+ // the booking's own person, also when an admin changes it
91
+ const owner = { ...options, user: this.ownerOf(stored) || options.user };
92
+ const merged = { ...stored, ...change };
93
+ if (merged.shift !== stored.shift || merged.customer !== stored.customer) {
94
+ await this.assertShiftAssignment([merged], owner, admin);
95
+ }
96
+ // the stored record may still carry the category older shift bookings were saved with
97
+ await this.assertPlanGrouping([merged], options, 'change');
98
+ await this.assertDailyLimit([merged], owner, id);
99
+ return this.immutable(this.scopeFilter(id, options), change, options.user, 'change', options.app);
100
+ }
101
+ async changeDescription(id, input, options) {
102
+ const stored = await this.scopedBooking(id, options);
103
+ if (!stored)
104
+ throw new ImmutableError('DOCUMENT_FOR_UPDATE_MISSING');
105
+ return this.immutable(this.scopeFilter(id, options), input, options.user, 'change', options.app, undefined, undefined, 'updateDescription');
106
+ }
107
+ async removeBooking(id, options) {
108
+ const stored = await this.scopedBooking(id, options);
109
+ if (!stored)
110
+ throw new ImmutableError('PARENT_DOCUMENT_NOT_FOUND');
111
+ // the scoped lookup above already proved the id belongs to this company and project
112
+ return this.delete(id, options.user, options.app);
113
+ }
114
+ /** Uses the tracked span when both ends are present, mirroring the duration stored by changeBooking(). */
115
+ dailyLimitDuration(entry) {
116
+ if (entry.startTime && entry.endTime) {
117
+ const duration = new Date(entry.endTime).getTime() - new Date(entry.startTime).getTime();
118
+ if (!Number.isNaN(duration))
119
+ return Math.max(duration, 0);
120
+ }
121
+ return entry.currentTime || 0;
122
+ }
123
+ /**
124
+ * The entries that count as one person's time: the ones booked for that customer, which an admin
125
+ * stores under their own account, and the ones the person's own accounts booked. Counting by the
126
+ * booking account alone charges everything an admin books for others to the admin.
127
+ */
128
+ async personFilter(customer, options) {
129
+ const byCustomer = { customer };
130
+ if (!ObjectId.isValid(customer))
131
+ return byCustomer;
132
+ const person = await CustomersModel.db.findOne({
133
+ _id: new ObjectId(customer), company: options.company,
134
+ }, { projection: { accounts: 1 } });
135
+ const accounts = person && 'accounts' in person
136
+ ? (person.accounts || []).filter((single) => single.project === options.project).map((single) => single.account)
137
+ : [];
138
+ return accounts.length ? { $or: [byCustomer, { user: { $in: accounts } }] } : byCustomer;
139
+ }
140
+ /**
141
+ * Rejects entries that would push a user's tracked time for one plan and calendar day above the
142
+ * plan's maximumHoursPerDay. Missing plans, plans without the option and entries without a plan
143
+ * fall back to the 24 hour ceiling so existing trackers keep working.
144
+ */
145
+ async assertDailyLimit(entries, options, excludeId) {
146
+ const groups = new Map();
147
+ entries.forEach((entry) => {
148
+ if (!entry.startTime)
149
+ return;
150
+ const startTime = new Date(entry.startTime);
151
+ if (Number.isNaN(startTime.getTime()))
152
+ return;
153
+ const key = `${entry.timeTrackingProject || ''}|${entry.customer || ''}|${dayjs(startTime).format('YYYY-MM-DD')}`;
154
+ const group = groups.get(key);
155
+ if (group) {
156
+ group.duration += this.dailyLimitDuration(entry);
157
+ return;
158
+ }
159
+ groups.set(key, {
160
+ timeTrackingProject: entry.timeTrackingProject,
161
+ customer: entry.customer,
162
+ startTime,
163
+ duration: this.dailyLimitDuration(entry),
164
+ });
165
+ });
166
+ for (const group of groups.values()) {
167
+ const plan = group.timeTrackingProject
168
+ ? await TimeTrackingProjectsService.singleTimeTrackingProject(group.timeTrackingProject, options)
169
+ : null;
170
+ const maximumHoursPerDay = plan?.maximumHoursPerDay || DEFAULT_MAXIMUM_HOURS_PER_DAY;
171
+ const query = {
172
+ company: options.company,
173
+ project: options.project,
174
+ ...(group.customer ? await this.personFilter(group.customer, options) : { user: options.user }),
175
+ startTime: {
176
+ $gte: dayjs(group.startTime).startOf('day').toDate(),
177
+ $lte: dayjs(group.startTime).endOf('day').toDate(),
178
+ },
179
+ };
180
+ if (group.timeTrackingProject)
181
+ query.timeTrackingProject = group.timeTrackingProject;
182
+ if (excludeId)
183
+ query._id = { $ne: excludeId };
184
+ const [usage] = await this.model.db.aggregate([
185
+ { $match: query },
186
+ { $group: { _id: null, time: { $sum: '$currentTime' } } },
187
+ ]).toArray();
188
+ const used = usage?.time || 0;
189
+ if (used + group.duration > maximumHoursPerDay * MILLISECONDS_PER_HOUR) {
190
+ throw new ImmutableError('TRACKER_DAILY_LIMIT_EXCEEDED');
191
+ }
192
+ }
193
+ }
194
+ /**
195
+ * The caller's own customer in this project, used to check the shifts the caller is assigned to.
196
+ * App is intentionally not pinned: the tracker client reaches the same flow with different
197
+ * `studio-app` ids (entry save uses the editor app, shift and allocation lookups use the tracker
198
+ * app), and the customer connection is what carries the assignment, not the app that asked.
199
+ */
200
+ callerCustomer(options) {
201
+ return CustomersModel.db.findOne({
202
+ company: options.company, type: 'person', emailConfirmed: true,
203
+ accounts: { $elemMatch: { account: options.user, project: options.project } },
204
+ }, { projection: { _id: 1 } });
205
+ }
206
+ /**
207
+ * Rejects entries referencing a shift the caller may not book on. A member only books their own
208
+ * customer, so every shift entry has to carry that customer in `assignments.customer`. A project
209
+ * admin books for the person on the shift, so the entry's own `customer` is what gets checked
210
+ * (an admin entry without one still falls back to their own customer, the "record time" screen).
211
+ * Entries without a shift are ignored, so every other tracking mode is unaffected.
212
+ */
213
+ async assertShiftAssignment(entries, options, admin = false) {
214
+ const shiftEntries = entries
215
+ .filter((entry) => typeof entry.shift === 'string' && entry.shift.length > 0);
216
+ if (!shiftEntries.length)
217
+ return;
218
+ const uniqueShiftIds = [...new Set(shiftEntries.map((entry) => entry.shift))];
219
+ if (uniqueShiftIds.some((shift) => !ObjectId.isValid(shift))) {
220
+ throw new ImmutableError('TRACKER_SHIFT_NOT_ASSIGNED');
221
+ }
222
+ // an admin who names the person on every entry needs no customer record of their own
223
+ const ownCustomer = (!admin || shiftEntries.some((entry) => !entry.customer))
224
+ ? await this.callerCustomer(options)
225
+ : null;
226
+ if (!admin && !ownCustomer)
227
+ throw new ImmutableError('TRACKER_SHIFT_NOT_ASSIGNED');
228
+ const shifts = await ShiftModel.db.find({
229
+ _id: { $in: uniqueShiftIds.map((shift) => new ObjectId(shift)) },
230
+ company: options.company,
231
+ project: options.project,
232
+ }, { projection: { assignments: 1 } }).toArray();
233
+ const assignmentsByShift = new Map(shifts.map((shift) => [shift._id.toHexString(), shift.assignments || []]));
234
+ for (const entry of shiftEntries) {
235
+ const assignments = assignmentsByShift.get(entry.shift) || [];
236
+ const customer = admin && entry.customer ? entry.customer : ownCustomer?._id?.toHexString();
237
+ if (!customer || !assignments.some((assignment) => String(assignment.customer) === customer)) {
238
+ throw new ImmutableError('TRACKER_SHIFT_NOT_ASSIGNED');
239
+ }
240
+ }
241
+ }
242
+ /**
243
+ * Rejects entries whose grouping does not match their plan. A shift-based plan books on shifts
244
+ * and must not carry a category; every other plan, and an entry without a plan, groups by
245
+ * category. Entries are checked against one plan lookup per distinct plan.
246
+ *
247
+ * A change is lenient: entries created before shift bookings existed carry a category next to
248
+ * their shift, so it only requires the result to still be grouped at all.
249
+ */
250
+ async assertPlanGrouping(entries, options, assertion = 'create') {
251
+ const shiftBasedPlans = new Map();
252
+ for (const entry of entries) {
253
+ // a change only has to leave the entry grouped, so a stored grouping skips the plan lookup
254
+ if (assertion === 'change' && (entry.shift || entry.category))
255
+ continue;
256
+ const timeTrackingProject = entry.timeTrackingProject;
257
+ let shiftBased = false;
258
+ if (timeTrackingProject) {
259
+ const known = shiftBasedPlans.get(timeTrackingProject);
260
+ if (known === undefined) {
261
+ const plan = await TimeTrackingProjectsService.singleTimeTrackingProject(timeTrackingProject, options);
262
+ shiftBased = plan?.modes === 'shifts' && !!plan.shiftPlan;
263
+ shiftBasedPlans.set(timeTrackingProject, shiftBased);
264
+ }
265
+ else {
266
+ shiftBased = known;
267
+ }
268
+ }
269
+ if (assertion === 'change') {
270
+ throw new ImmutableError(shiftBased ? 'TRACKER_SHIFT_REQUIRED' : 'TRACKER_CATEGORY_REQUIRED');
271
+ }
272
+ if (shiftBased) {
273
+ if (!entry.shift)
274
+ throw new ImmutableError('TRACKER_SHIFT_REQUIRED');
275
+ if (entry.category)
276
+ throw new ImmutableError('TRACKER_CATEGORY_NOT_ALLOWED');
277
+ }
278
+ else if (!entry.category) {
279
+ throw new ImmutableError('TRACKER_CATEGORY_REQUIRED');
280
+ }
281
+ }
282
+ }
283
+ /** the owner of an entry, the id when the user is not resolved to a profile */
284
+ ownerOf(tracker) {
285
+ return typeof tracker.user === 'string' ? tracker.user : tracker.user?.id;
286
+ }
287
+ /** `tracker:admin` reads and changes the entries of everybody, not just the caller's own */
288
+ async isTrackerAdmin(options) {
289
+ const permission = await MeService.getSinglePermission(options.user, options.company, options.project);
290
+ return Boolean(permission?.scope?.includes('tracker:admin'));
291
+ }
292
+ /**
293
+ * Changing or removing a booking needs the route's scope and, on top of it, the booking has to be the caller's
294
+ * own or the caller has `tracker:admin`. The scope guard runs first, so owning a booking never replaces it.
295
+ * A booking that does not exist is not decided here, the service answers it as missing.
296
+ */
297
+ async mayChangeBooking(stored, options) {
298
+ if (!stored)
299
+ return true;
300
+ if (this.ownerOf(stored) === options.user)
301
+ return true;
302
+ return this.isTrackerAdmin(options);
303
+ }
304
+ /** preHandler for the routes that change or remove one booking, see mayChangeBooking() */
305
+ ownBookingOrAdmin() {
306
+ return async (request, reply) => {
307
+ if (!request.auth)
308
+ return respondCode('UNAUTHORIZED', reply);
309
+ try {
310
+ const options = { user: request.auth.sub, company: request.params.company, project: request.params.project };
311
+ const stored = await this.scopedBooking(new ObjectId(request.params.id), options);
312
+ if (!await this.mayChangeBooking(stored, options))
313
+ return respondCode('FORBIDDEN', reply);
314
+ }
315
+ catch (error) {
316
+ return handleError(error, reply);
317
+ }
318
+ return undefined;
319
+ };
320
+ }
321
+ /**
322
+ * An admin may book for another person: on a shift for the person assigned to it, or anywhere by
323
+ * naming their customer. Only asked when an entry carries a shift or a customer, so the ordinary
324
+ * tracking modes cost no permission lookup.
325
+ */
326
+ async bookingAdmin(entries, options) {
327
+ if (!entries.some((entry) => entry.shift || entry.customer))
328
+ return false;
329
+ return this.isTrackerAdmin(options);
330
+ }
331
+ withoutCustomer(entry) {
332
+ const booking = { ...entry };
333
+ delete booking.customer;
334
+ return booking;
335
+ }
336
+ /**
337
+ * ISO-normalized booking window; the same overlap convention the shift planning filters use.
338
+ * Both ends are required, the tracker only works with windows it can bound.
339
+ */
340
+ bookingInterval(from, to) {
341
+ const instant = (value, path) => {
342
+ if (!value) {
343
+ throw new ImmutableError('VALIDATION_FAILED', [{
344
+ instancePath: path, schemaPath: '#/bookingInterval', keyword: 'bookingInterval', params: {}, message: 'is required',
345
+ }]);
346
+ }
347
+ const parsed = Date.parse(value);
348
+ if (!Number.isFinite(parsed)) {
349
+ throw new ImmutableError('VALIDATION_FAILED', [{
350
+ instancePath: path, schemaPath: '#/bookingInterval', keyword: 'bookingInterval', params: {}, message: 'must be a valid instant',
351
+ }]);
352
+ }
353
+ return new Date(parsed).toISOString();
354
+ };
355
+ const interval = { from: instant(from, '/query/from'), to: instant(to, '/query/to') };
356
+ if (interval.to <= interval.from) {
357
+ throw new ImmutableError('VALIDATION_FAILED', [{
358
+ instancePath: '/query/to', schemaPath: '#/bookingInterval', keyword: 'bookingInterval', params: {}, message: 'must be after from',
359
+ }]);
360
+ }
361
+ return interval;
362
+ }
363
+ /**
364
+ * The shifts the caller may book tracked time on: the shifts of the plan that carry the caller's
365
+ * own customer in `assignments.customer` and overlap the booking window. The narrow projection
366
+ * lets the tracker app offer them without `shifts:read`.
367
+ */
368
+ async bookableShifts(timeTrackingProject, from, to, options) {
369
+ const interval = this.bookingInterval(from, to);
370
+ const plan = await TimeTrackingProjectsService.singleTimeTrackingProject(timeTrackingProject, options);
371
+ if (plan?.modes !== 'shifts' || !plan.shiftPlan)
372
+ return [];
373
+ const customer = await this.callerCustomer(options);
374
+ // a caller without a connected customer is assigned to no shift at all
375
+ if (!customer)
376
+ return [];
377
+ const query = {
378
+ company: options.company,
379
+ project: options.project,
380
+ plan: new ObjectId(plan.shiftPlan),
381
+ assignments: { $elemMatch: { customer: customer._id } },
382
+ end: { $gt: interval.from },
383
+ start: { $lt: interval.to },
384
+ };
385
+ const shifts = await ShiftModel.db.find(query, { projection: { title: 1, start: 1, end: 1 } })
386
+ .sort({ start: 1 })
387
+ .toArray();
388
+ return shifts.map((shift) => ({
389
+ _id: shift._id.toHexString(),
390
+ title: shift.title,
391
+ start: shift.start,
392
+ end: shift.end,
393
+ }));
394
+ }
395
+ /**
396
+ * Every time entry booked on a shift of a time tracking project, so each booking can be edited
397
+ * or removed on its own. An entry names its person in `customer` only when an admin booked it;
398
+ * a member's own entry carries just the account, so that account is resolved to its connected
399
+ * customer.
400
+ */
401
+ async shiftBookings(timeTrackingProject, options) {
402
+ const entries = await this.model.db.find({
403
+ company: options.company,
404
+ project: options.project,
405
+ timeTrackingProject,
406
+ shift: { $type: 'string', $ne: '' },
407
+ }, { projection: { shift: 1, customer: 1, user: 1, startTime: 1, endTime: 1, currentTime: 1 } })
408
+ .sort({ startTime: 1 })
409
+ .toArray();
410
+ const accounts = [...new Set(entries
411
+ .filter((entry) => !entry.customer && typeof entry.user === 'string')
412
+ .map((entry) => entry.user))];
413
+ const customersByAccount = new Map();
414
+ if (accounts.length) {
415
+ const customers = await CustomersModel.db.find({
416
+ company: options.company,
417
+ type: 'person',
418
+ accounts: { $elemMatch: { account: { $in: accounts }, project: options.project } },
419
+ }, { projection: { _id: 1, accounts: 1 } }).toArray();
420
+ customers.forEach((customer) => {
421
+ if (!('accounts' in customer))
422
+ return;
423
+ customer.accounts?.forEach((single) => {
424
+ if (single.project === options.project)
425
+ customersByAccount.set(single.account, customer._id.toHexString());
426
+ });
427
+ });
428
+ }
429
+ return entries.map((entry) => {
430
+ const customer = entry.customer || (typeof entry.user === 'string' ? customersByAccount.get(entry.user) : undefined);
431
+ return {
432
+ id: entry._id.toHexString(),
433
+ shift: entry.shift,
434
+ ...(customer ? { customer } : {}),
435
+ startTime: entry.startTime ? new Date(entry.startTime).toISOString() : undefined,
436
+ endTime: entry.endTime ? new Date(entry.endTime).toISOString() : undefined,
437
+ currentTime: entry.currentTime || 0,
438
+ };
439
+ });
440
+ }
441
+ single(internalId, options) {
442
+ return this.model.db.aggregate([
443
+ {
444
+ $match: { _id: internalId, company: options.company, project: options.project },
445
+ },
446
+ ...lookupUserAggregation,
447
+ ])
448
+ .toArray()
449
+ .then((tracker) => {
450
+ if (!tracker.length)
451
+ return null;
452
+ return tracker[0];
453
+ });
454
+ }
455
+ /**
456
+ * The entries of one plan inside a range, newest last, each with the name of the person who booked it.
457
+ * `user` is the person to list, or TRACKER_ALL_USERS; the caller decides who may ask for more than their own.
458
+ */
459
+ async bookings(timeTrackingProject, range, user, options) {
460
+ const interval = this.bookingInterval(range.from, range.to);
461
+ const from = new Date(interval.from);
462
+ const to = new Date(interval.to);
463
+ if ((to.getTime() - from.getTime()) / (24 * MILLISECONDS_PER_HOUR) > MAXIMUM_BOOKING_RANGE_DAYS) {
464
+ throw new ImmutableError('VALIDATION_FAILED', [{
465
+ instancePath: '/query/to', schemaPath: '#/bookingRange', keyword: 'bookingRange', params: { limit: MAXIMUM_BOOKING_RANGE_DAYS }, message: `must be at most ${MAXIMUM_BOOKING_RANGE_DAYS} days after from`,
466
+ }]);
467
+ }
468
+ const query = {
469
+ company: options.company,
470
+ project: options.project,
471
+ timeTrackingProject,
472
+ startTime: { $gte: from, $lt: to },
473
+ };
474
+ if (user !== TRACKER_ALL_USERS)
475
+ query.user = user;
476
+ const entries = await this.model.db.find(query, { projection: { history: 0 } })
477
+ .sort({ startTime: 1 })
478
+ .limit(MAXIMUM_BOOKINGS)
479
+ .toArray();
480
+ const accounts = [...new Set(entries.filter((entry) => typeof entry.user === 'string').map((entry) => entry.user))];
481
+ const people = accounts.length
482
+ ? await MeModel.db.find({ id: { $in: accounts } }, { projection: { id: 1, prename: 1, lastname: 1 } }).toArray()
483
+ : [];
484
+ const peopleById = new Map(people.map((person) => [person.id, person]));
485
+ return entries.map((entry) => {
486
+ const account = typeof entry.user === 'string' ? entry.user : entry.user?.id;
487
+ const person = peopleById.get(account);
488
+ return {
489
+ id: entry._id.toHexString(),
490
+ ...(entry.category ? { category: entry.category } : {}),
491
+ ...(entry.shift ? { shift: entry.shift } : {}),
492
+ ...(entry.description ? { description: entry.description } : {}),
493
+ user: { id: account, ...(person?.prename ? { prename: person.prename } : {}), ...(person?.lastname ? { lastname: person.lastname } : {}) },
494
+ startTime: entry.startTime ? new Date(entry.startTime).toISOString() : undefined,
495
+ endTime: entry.endTime ? new Date(entry.endTime).toISOString() : undefined,
496
+ currentTime: entry.currentTime || 0,
497
+ };
498
+ });
499
+ }
500
+ /**
501
+ * Hours a person was allocated per category against everything they booked on the plan so far.
502
+ * One aggregation sums all categories, so the cost does not grow with the number of allocations.
503
+ */
504
+ async allocationUsage(timeTrackingProject, user, options) {
505
+ const scope = {
506
+ company: options.company, project: options.project, timeTrackingProject, user,
507
+ };
508
+ const allocations = await TimeTrackingProjectAllocationModel.db
509
+ .find(scope)
510
+ .toArray();
511
+ if (!allocations.length)
512
+ return [];
513
+ const sums = await this.model.db.aggregate([
514
+ { $match: { ...scope, category: { $in: allocations.map((allocation) => allocation.category) } } },
515
+ { $group: { _id: '$category', bookedTime: { $sum: '$currentTime' } } },
516
+ ]).toArray();
517
+ const bookedByCategory = new Map(sums.map((sum) => [sum._id, sum.bookedTime]));
518
+ return allocations
519
+ .map((allocation) => ({
520
+ category: allocation.category,
521
+ hours: allocation.hours,
522
+ bookedTime: bookedByCategory.get(allocation.category) || 0,
523
+ }))
524
+ .sort((first, second) => (first.category < second.category ? -1 : Number(first.category > second.category)));
525
+ }
526
+ async statistics(user, company, project, timeTrackingProject, dateRange) {
527
+ const useMonth = dateRange?.month ? dateRange.month - 1 : dayjs().get('month');
528
+ const useYear = dateRange?.year ? dateRange.year : dayjs().get('year');
529
+ const start = dayjs().set('month', useMonth).set('year', useYear).startOf('month').toDate();
530
+ const end = dayjs().set('month', useMonth).set('year', useYear).endOf('month').toDate();
531
+ const query = { company, project };
532
+ // the whole plan ignores the month, so its statistics do not change with the shown period
533
+ if (!dateRange?.wholePlan)
534
+ query.startTime = { $gte: start, $lte: end };
535
+ if (user !== TRACKER_ALL_USERS)
536
+ query.user = user;
537
+ if (timeTrackingProject)
538
+ query.timeTrackingProject = timeTrackingProject;
539
+ const buckets = await this.model.db.aggregate([
540
+ { $match: query },
541
+ {
542
+ $group: {
543
+ _id: {
544
+ // the server's zone, the same day boundary the month filter above uses
545
+ day: { $dateToString: { format: '%Y-%m-%d', date: '$startTime', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone } },
546
+ // a shift booking groups by its shift, every other entry by its category
547
+ group: { $ifNull: ['$shift', '$category'] },
548
+ },
549
+ count: { $sum: 1 },
550
+ time: { $sum: '$currentTime' },
551
+ first: { $min: '$startTime' },
552
+ last: { $max: '$startTime' },
553
+ },
554
+ },
555
+ ]).toArray();
556
+ const statistics = this.statisticsOf(buckets);
557
+ if (dateRange?.wholePlan) {
558
+ // an entry without a start has no place on the timeline, so it cannot widen the range
559
+ const firsts = buckets.flatMap((bucket) => (bucket.first ? [new Date(bucket.first).getTime()] : []));
560
+ const lasts = buckets.flatMap((bucket) => (bucket.last ? [new Date(bucket.last).getTime()] : []));
561
+ return {
562
+ ...statistics,
563
+ start: firsts.length ? new Date(Math.min(...firsts)) : start,
564
+ end: lasts.length ? new Date(Math.max(...lasts)) : end,
565
+ };
566
+ }
567
+ return {
568
+ ...statistics,
569
+ start,
570
+ end,
571
+ };
572
+ }
573
+ async day(day, options) {
574
+ const query = { ...options };
575
+ delete query.app;
576
+ if (query.user === TRACKER_ALL_USERS)
577
+ delete query.user;
578
+ const dayStart = dayjs(day).startOf('day').toDate();
579
+ const dayEnd = dayjs(day).endOf('day').toDate();
580
+ query.startTime = { $gte: dayStart, $lte: dayEnd };
581
+ const data = await this.model.db.find(query, { projection: { history: 0 } })
582
+ .limit(100)
583
+ .sort({ startTime: 1 })
584
+ .toArray();
585
+ return { data, count: data.length, statistics: this.createStatistics(data, 'day', day), date: dayStart };
586
+ }
587
+ /** the categories used in this company and project; the search matches literally, it is no pattern */
588
+ async categories(search, options) {
589
+ const match = {
590
+ company: options.company,
591
+ project: options.project,
592
+ // shift bookings carry no category, so the set only collects real ones
593
+ category: search ? { $regex: new RegExp(escapeRegExp(search)) } : { $type: 'string' },
594
+ };
595
+ const categories = (await this.model.db.distinct('category', match)).map(String);
596
+ // matchSortArray would read the search as a pattern again, so the earliest literal hit sorts first here
597
+ return categories.sort((first, second) => first.indexOf(search) - second.indexOf(search));
598
+ }
599
+ /**
600
+ * Turns the day and group buckets of the statistics aggregation into the summary. Entries without a
601
+ * group count towards `count` only, the same as entries the tracker cannot place in any bucket.
602
+ */
603
+ statisticsOf(buckets) {
604
+ const categories = {};
605
+ const byMonth = {};
606
+ const byDay = {};
607
+ const all = { count: 0, time: 0 };
608
+ let count = 0;
609
+ const add = (target, key, bucket) => {
610
+ const data = target[key] || { count: 0, time: 0 };
611
+ data.count += bucket.count;
612
+ data.time += bucket.time || 0;
613
+ target[key] = data;
614
+ };
615
+ buckets.forEach((bucket) => {
616
+ const { day, group } = bucket._id;
617
+ const month = day?.slice(0, 7);
618
+ count += bucket.count;
619
+ if (day && month) {
620
+ byDay[day] = byDay[day] || {};
621
+ byMonth[month] = byMonth[month] || {};
622
+ }
623
+ if (!group)
624
+ return;
625
+ add(categories, group, bucket);
626
+ all.count += bucket.count;
627
+ all.time += bucket.time || 0;
628
+ if (day && month) {
629
+ add(byDay[day], group, bucket);
630
+ add(byDay[day], 'all', bucket);
631
+ add(byMonth[month], group, bucket);
632
+ add(byMonth[month], 'all', bucket);
633
+ }
634
+ });
635
+ return {
636
+ count,
637
+ all,
638
+ categories: { all: categories, byMonth, byDay },
639
+ };
640
+ }
641
+ // the caller adds the range bounds, so the summary is only partial here
642
+ createStatistics(data, range, rangeIdentifier) {
643
+ const categoryObject = {};
644
+ const monthObject = {};
645
+ const daysObject = {};
646
+ const allObject = { count: 0, time: 0 };
647
+ data.forEach((single) => {
648
+ const singleStartMonth = dayjs(single.startTime).format('YYYY-MM');
649
+ const singleStartDay = dayjs(single.startTime).format('YYYY-MM-DD');
650
+ // a shift booking groups by its shift, every other entry by its category
651
+ const group = single.shift || single.category;
652
+ if (!monthObject[singleStartMonth])
653
+ monthObject[singleStartMonth] = {};
654
+ if (!daysObject[singleStartDay])
655
+ daysObject[singleStartDay] = {};
656
+ // creates a alias for the object annotation for better readability
657
+ let singleMonth = monthObject[singleStartMonth];
658
+ // creates a alias for the object annotation for better readability
659
+ let singleDay = daysObject[singleStartDay];
660
+ if (group) {
661
+ if (!singleDay.all)
662
+ singleDay.all = { count: 0, time: 0 };
663
+ if (!singleMonth.all)
664
+ singleMonth.all = { count: 0, time: 0 };
665
+ if (!singleMonth[group])
666
+ singleMonth[group] = { count: 0, time: 0 };
667
+ // creates a alias for the object annotation for better readability
668
+ const monthCategory = singleMonth[group];
669
+ if (!singleDay[group])
670
+ singleDay[group] = { count: 0, time: 0 };
671
+ // creates a alias for the object annotation for better readability
672
+ const dayCategory = singleDay[group];
673
+ if (!categoryObject[group])
674
+ categoryObject[group] = { count: 0, time: 0 };
675
+ // creates a alias for the object annotation for better readability
676
+ const mainCategory = categoryObject[group];
677
+ mainCategory.count++;
678
+ monthCategory.count++;
679
+ dayCategory.count++;
680
+ singleDay.all.count++;
681
+ singleMonth.all.count++;
682
+ allObject.count++;
683
+ if (single.currentTime) {
684
+ mainCategory.time += single.currentTime;
685
+ monthCategory.time += single.currentTime;
686
+ dayCategory.time += single.currentTime;
687
+ singleDay.all.time += single.currentTime;
688
+ singleMonth.all.time += single.currentTime;
689
+ allObject.time += single.currentTime;
690
+ }
691
+ }
692
+ });
693
+ if (range === 'day' && rangeIdentifier) {
694
+ return daysObject[rangeIdentifier];
695
+ }
696
+ if (range === 'month' && rangeIdentifier) {
697
+ return monthObject[rangeIdentifier];
698
+ }
699
+ return {
700
+ count: data.length,
701
+ all: allObject,
702
+ categories: {
703
+ all: categoryObject,
704
+ byMonth: monthObject,
705
+ byDay: daysObject,
706
+ },
707
+ };
708
+ }
709
+ /** preHandler that removes the `user` query for anybody without the given scopes, so they only see their own entries */
710
+ filterAdmin(scope) {
711
+ return async (request, reply) => {
712
+ if (!request.auth)
713
+ return respondCode('UNAUTHORIZED', reply);
714
+ try {
715
+ const permission = await MeService.getSinglePermission(request.auth.sub, request.params.company, request.params.project);
716
+ if (!permission)
717
+ throw new Error('NO_PERMISSIONS_FOUND');
718
+ const foundScope = permission.scope?.filter((single) => scope.includes(single)) || [];
719
+ if (foundScope.length !== scope.length) {
720
+ delete request.query.user;
721
+ }
722
+ }
723
+ catch (error) {
724
+ console.error(error, scope, request.auth.sub, request.params.company, request.params.project);
725
+ return respondCode('FORBIDDEN', reply);
726
+ }
727
+ return undefined;
728
+ };
729
+ }
730
+ }
731
+ export default new TrackerService();
732
+ //# sourceMappingURL=tracker.service.js.map