@janiscommerce/app-tracking-shift 1.0.1

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/lib/Shift.js ADDED
@@ -0,0 +1,538 @@
1
+ import TimeTracker from './db/TimeTrackerService';
2
+ import StaffService from './StaffApiServices';
3
+ import Storage from './db/StorageService';
4
+ import Crashlytics from './utils/crashlytics';
5
+ import ShiftWorklogs from './ShiftWorklogs';
6
+ import {generateRandomId, isEmptyArray, isEmptyObject, isObject} from './utils/helpers';
7
+ import {getObject, getShiftData, setObject} from './utils/storage';
8
+ import {
9
+ SHIFT_ID,
10
+ SHIFT_STATUS,
11
+ SHIFT_DATA,
12
+ CURRENT_WORKLOG_DATA,
13
+ CURRENT_WORKLOG_ID,
14
+ EXCLUDED_WORKLOG_TYPES,
15
+ ONE_HOUR_EXTENSION,
16
+ } from './constant';
17
+ import TrackerRecords from './TrackerRecords';
18
+ import Formatter from './Formatter';
19
+ import OfflineData from './OfflineData';
20
+ /**
21
+ * Class to manage work shifts
22
+ * @class Shift
23
+ */
24
+
25
+ class Shift {
26
+ /**
27
+ * Open a work shift in the staff MS and record this event in the time tracking database.
28
+ * @param {Object} params
29
+ * @throws {Error} error
30
+ * @returns {Promise<string>} shiftId => ID related to the shift that has just been opened for the user
31
+ */
32
+
33
+ async checkStaffMSAuthorization() {
34
+ try {
35
+ const {result: setting} = await StaffService.getSetting('global');
36
+
37
+ const {enabledShiftAndWorkLog = false} = setting || {};
38
+
39
+ return enabledShiftAndWorkLog;
40
+ } catch (error) {
41
+ Crashlytics.recordError(error, 'Error checking staff MS authorization');
42
+ return Promise.reject(error);
43
+ }
44
+ }
45
+
46
+ async open(params = {}) {
47
+ try {
48
+ Crashlytics.log('user open shift');
49
+ const {date} = params;
50
+ const {result: shift} = await StaffService.openShift();
51
+ const {id: shiftId = ''} = shift || {};
52
+
53
+ const openShift = await this.getUserOpenShift({id: shiftId});
54
+
55
+ await this._startTracking({
56
+ id: shiftId,
57
+ date: date || openShift.startDate,
58
+ });
59
+
60
+ Storage.set(SHIFT_ID, shiftId);
61
+ Storage.set(SHIFT_STATUS, 'opened');
62
+ Storage.set(SHIFT_DATA, JSON.stringify(openShift));
63
+
64
+ return shiftId;
65
+ } catch (error) {
66
+ Crashlytics.recordError(error, 'Error opening shift in staff service');
67
+ return Promise.reject(error);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Finish a work shift in the staff MS and record this event in the time tracking database.
73
+ * @param {Object} params
74
+ * @throws {Error} error
75
+ * @returns {Promise<string>} shiftId => ID related to the shift that has just been closed for the user
76
+ */
77
+
78
+ async finish(params = {}) {
79
+ try {
80
+ Crashlytics.log('user close shift');
81
+ const shiftIsExpired = this.isDateToCloseExceeded();
82
+
83
+ if (shiftIsExpired) {
84
+ await this.reOpen();
85
+ }
86
+
87
+ if (OfflineData.hasData) {
88
+ await this.sendPendingWorkLogs();
89
+ }
90
+
91
+ const {date} = params;
92
+ const {result: shift} = await StaffService.closeShift();
93
+ const {id: shiftId = ''} = shift || {};
94
+ const endDate = date || new Date().toISOString();
95
+ const shiftData = getShiftData();
96
+
97
+ await this._finishTracking({id: shiftId, date});
98
+
99
+ const updatedShiftData = {
100
+ ...shiftData,
101
+ endDate,
102
+ };
103
+
104
+ Storage.set(SHIFT_STATUS, 'closed');
105
+ Storage.set(SHIFT_DATA, JSON.stringify(updatedShiftData));
106
+
107
+ return shiftId;
108
+ } catch (error) {
109
+ Crashlytics.recordError(error, 'Error closing shift in staff service');
110
+ return Promise.reject(error);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Open a work log in the staff MS and record this event in the time tracking database.
116
+ * @param {Object} workLog
117
+ * @param {string} workLog.referenceId => Reference ID related to the work log
118
+ * @param {string} workLog.name => Name related to the work log
119
+ * @param {string} workLog.type => Type related to the work log
120
+ * @param {number} workLog.suggestedTime => Suggested time related to the work log
121
+ * @throws {Error} error
122
+ * @returns {Promise<string>} workLogId => ID related to the work log that has just been opened for the user
123
+ */
124
+
125
+ async openWorkLog(workLog = {}) {
126
+ try {
127
+ Crashlytics.log('user open shift worklog', workLog);
128
+
129
+ if (!isObject(workLog) || isEmptyObject(workLog)) return null;
130
+
131
+ const {referenceId, name, type, suggestedTime = 0} = workLog;
132
+ const shiftId = Storage.getString(SHIFT_ID);
133
+ const startTime = new Date().toISOString();
134
+ const randomId = generateRandomId();
135
+
136
+ const workLogId = Formatter.formatWorkLogId(referenceId, randomId);
137
+
138
+ // TODO: uncomment this when resolve how to handle offline workLogs
139
+ // await ShiftWorklogs.open({
140
+ // referenceId,
141
+ // startDate: startTime,
142
+ // });
143
+
144
+ const dataForTimeTracker = {
145
+ type,
146
+ name,
147
+ shiftId,
148
+ referenceId,
149
+ };
150
+
151
+ OfflineData.save(workLogId, {
152
+ referenceId,
153
+ startDate: startTime,
154
+ });
155
+
156
+ await this._startTracking({
157
+ id: workLogId,
158
+ date: startTime,
159
+ payload: dataForTimeTracker,
160
+ });
161
+
162
+ const suggestedFinishDate = new Date(startTime).getTime() + suggestedTime * 60 * 1000;
163
+
164
+ const dataForStorage = {
165
+ ...dataForTimeTracker,
166
+ suggestedFinishDate: new Date(suggestedFinishDate).toISOString(),
167
+ suggestedTime,
168
+ startDate: startTime,
169
+ };
170
+
171
+ if (!EXCLUDED_WORKLOG_TYPES.includes(referenceId)) {
172
+ Storage.set(SHIFT_STATUS, 'paused');
173
+ }
174
+
175
+ Storage.set(CURRENT_WORKLOG_ID, workLogId);
176
+ Storage.set(CURRENT_WORKLOG_DATA, JSON.stringify(dataForStorage));
177
+
178
+ return workLogId;
179
+ } catch (error) {
180
+ Crashlytics.recordError(error, 'Error opening shift worklog');
181
+ return Promise.reject(error);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Finish a work log in the staff MS and record this event in the time tracking database.
187
+ * @param {Object} workLog
188
+ * @param {string} workLog.referenceId => Reference ID related to the work log
189
+ * @param {string} workLog.name => Name related to the work log
190
+ * @param {string} workLog.type => Type related to the work log
191
+ * @throws {Error} error
192
+ * @returns {Promise<string>} workLogId => ID related to the work log that has just been closed for the user
193
+ */
194
+
195
+ async finishWorkLog(workLog = {}) {
196
+ try {
197
+ Crashlytics.log('user close shift worklog', workLog);
198
+ if (!isObject(workLog) || isEmptyObject(workLog)) return null;
199
+
200
+ const {referenceId, name, type} = workLog;
201
+ const shiftId = Storage.getString(SHIFT_ID);
202
+ const shiftStatus = Storage.getString(SHIFT_STATUS);
203
+ const endTime = new Date().toISOString();
204
+ const workLogId = Storage.getString(CURRENT_WORKLOG_ID);
205
+
206
+ // TODO: uncomment this when resolve how to handle offline workLogs
207
+ // await ShiftWorklogs.finish({
208
+ // referenceId,
209
+ // endDate: endTime,
210
+ // });
211
+
212
+ const dataForTimeTracker = {
213
+ type,
214
+ name,
215
+ shiftId,
216
+ referenceId,
217
+ };
218
+
219
+ OfflineData.save(workLogId, {
220
+ referenceId,
221
+ endDate: endTime,
222
+ });
223
+
224
+ await this._finishTracking({
225
+ id: workLogId,
226
+ date: endTime,
227
+ payload: dataForTimeTracker,
228
+ });
229
+
230
+ if (shiftStatus === 'paused') {
231
+ Storage.set(SHIFT_STATUS, 'opened');
232
+ }
233
+
234
+ this._deleteCurrentWorkLogData();
235
+
236
+ return workLogId;
237
+ } catch (error) {
238
+ Crashlytics.recordError(error, 'Error closing shift worklog');
239
+ return Promise.reject(error);
240
+ }
241
+ }
242
+
243
+ async sendPendingWorkLogs() {
244
+ try {
245
+ const storageData = OfflineData.get();
246
+ const formatedWorkLogs = Formatter.formatOfflineWorkLog(storageData);
247
+
248
+ if (isEmptyArray(formatedWorkLogs)) return null;
249
+
250
+ const shiftIsExpired = this.isDateToCloseExceeded();
251
+
252
+ if (shiftIsExpired) {
253
+ await this.reOpen();
254
+ }
255
+
256
+ await ShiftWorklogs.postPendingBatch(formatedWorkLogs);
257
+ OfflineData.deleteAll();
258
+ return null;
259
+ } catch (error) {
260
+ Crashlytics.recordError(error, 'Error posting pending work logs');
261
+ return Promise.reject(error);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Get the open shift for a user
267
+ * @param {Object} params
268
+ * @param {string} params.userId => ID related to the user
269
+ * @param {string} params.id => ID related to the shift
270
+ * @throws {Error} error
271
+ * @returns {Promise<Object>} shift => The open shift for the user
272
+ */
273
+
274
+ async getUserOpenShift(params = {}) {
275
+ try {
276
+ Crashlytics.log('user get open shift', params);
277
+ const {userId, id, ...rest} = params;
278
+ const {result: shifts} = await StaffService.getShiftsList({
279
+ filters: {
280
+ userId,
281
+ status: 'opened',
282
+ ...(!!id && {id}),
283
+ ...rest,
284
+ },
285
+ });
286
+ const [openShift] = shifts || [];
287
+
288
+ return openShift || {};
289
+ } catch (error) {
290
+ Crashlytics.recordError(error, 'Error getting open shift in staff service');
291
+ return Promise.reject(error);
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Get the work logs for a shift
297
+ * @param {string} shiftId => ID related to the shift
298
+ * @throws {Error} error
299
+ * @returns {Promise<Array>} workLogs => Array of work logs
300
+ */
301
+
302
+ async getWorkLogs(shiftId) {
303
+ try {
304
+ Crashlytics.log('user get work logs');
305
+ const userShiftId = shiftId || Storage.getString(SHIFT_ID);
306
+
307
+ if (!userShiftId) throw new Error('Shift ID not found');
308
+
309
+ const workLogs = await ShiftWorklogs.getList(userShiftId);
310
+
311
+ return Formatter.formatWorkLogsFromJanis(workLogs);
312
+ } catch (error) {
313
+ Crashlytics.recordError(error, 'Error getting work logs in staff service');
314
+ return Promise.reject(error);
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Re open current shift in the staff MS and extend storage shift closing date.
320
+ * @throws {Error} error
321
+ * @returns {Promise<null>} null
322
+ */
323
+
324
+ async reOpen() {
325
+ try {
326
+ Crashlytics.log('user re open shift');
327
+ const shiftIsExpired = this.isDateMaxToCloseExceeded();
328
+
329
+ if (shiftIsExpired) {
330
+ throw new Error('The deadline for ending the shift has been exceeded');
331
+ }
332
+ await StaffService.openShift();
333
+ this._extendShiftClosingDate();
334
+
335
+ return null;
336
+ } catch (error) {
337
+ Crashlytics.recordError(error, 'Error re opening shift');
338
+ return Promise.reject(error);
339
+ }
340
+ }
341
+
342
+ isDateToCloseExceeded() {
343
+ const shiftData = getObject(SHIFT_DATA);
344
+ const {dateToClose} = shiftData;
345
+ const currentDate = new Date();
346
+
347
+ return new Date(dateToClose).getTime() < currentDate.getTime();
348
+ }
349
+
350
+ isDateMaxToCloseExceeded() {
351
+ const shiftData = getObject(SHIFT_DATA);
352
+ const {dateMaxToClose} = shiftData;
353
+ const currentDate = new Date();
354
+
355
+ return new Date(dateMaxToClose).getTime() < currentDate.getTime();
356
+ }
357
+
358
+ /**
359
+ * Gets a shift report based on the shift ID and its registered events.
360
+ *
361
+ * The report includes information about performed activities, start and end times,
362
+ * total elapsed time, work time and pause time.
363
+ *
364
+ * @async
365
+ * @function getReport
366
+ * @throws {Error} If the `shiftId` is not found in storage.
367
+ * @returns {Promise<{
368
+ * activities: Array<{
369
+ * id: string,
370
+ * name: string,
371
+ * type: string,
372
+ * startDate: string,
373
+ * endDate: string,
374
+ * duration: number
375
+ * }>,
376
+ * startDate: string,
377
+ * endDate: string,
378
+ * elapsedTime: number,
379
+ * workTime: number,
380
+ * pauseTime: number,
381
+ * isComplete: boolean,
382
+ * error: string | null
383
+ * }>} Object with shift details.
384
+ *
385
+ * @property {Array} activities - List of activities registered during the shift.
386
+ * @property {string} activities[].id - Unique identifier of the activity.
387
+ * @property {string} activities[].name - Name or description of the activity.
388
+ * @property {string} activities[].startDate - Start date and time of the activity.
389
+ * @property {string} activities[].endDate - End date and time of the activity.
390
+ * @property {number} activities[].duration - Duration of the activity in milliseconds.
391
+ * @property {string} startDate - Start date and time of the shift.
392
+ * @property {string} endDate - End date and time of the shift (can be empty if not finished).
393
+ * @property {number} elapsedTime - Total elapsed time between shift start and end.
394
+ * @property {number} workTime - Effective work time (total time minus pauses).
395
+ * @property {number} pauseTime - Total time of registered pauses.
396
+ * @property {boolean} isComplete - Indicates if the report was obtained completely without errors.
397
+ * @property {string|null} error - Error message if the report is not complete, or null if there were no errors.
398
+ */
399
+
400
+ async getReport() {
401
+ try {
402
+ Crashlytics.log('user get shift report');
403
+ const shiftId = Storage.getString(SHIFT_ID);
404
+
405
+ if (!shiftId) throw new Error('Shift ID is required');
406
+
407
+ const startDate = await TrackerRecords.getStartDateById(shiftId);
408
+ const endDate = await TrackerRecords.getEndDateById(shiftId);
409
+ const workLogs = await ShiftWorklogs.getShiftTrackedWorkLogs(shiftId);
410
+
411
+ const activities = Formatter.formatShiftActivities(workLogs);
412
+ const elapsedTime = TimeTracker.getElapsedTime({
413
+ startTime: startDate,
414
+ ...(!!endDate && {endTime: endDate}),
415
+ format: false,
416
+ });
417
+
418
+ const pauseTime = activities.reduce((acc, activity) => acc + (activity?.duration || 0), 0);
419
+ const workTime = elapsedTime - pauseTime;
420
+
421
+ return {
422
+ activities,
423
+ startDate,
424
+ endDate,
425
+ elapsedTime,
426
+ workTime,
427
+ pauseTime,
428
+ };
429
+ } catch (reason) {
430
+ Crashlytics.recordError(reason, 'Error getting shift report');
431
+ return Promise.reject(reason);
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Fetch the work log types from the staff MS and prepare them for register an activity.
437
+ * @throws {Error} error
438
+ * @returns {Promise<Array>} workLogTypes => Array of work log types
439
+ */
440
+
441
+ async fetchWorklogTypes() {
442
+ try {
443
+ Crashlytics.log('user fetch worklog types');
444
+ const {result: workLogTypes = []} = await StaffService.getWorkLogTypes();
445
+
446
+ return Formatter.formatWorkLogTypes(workLogTypes);
447
+ } catch (error) {
448
+ Crashlytics.recordError(error, 'Error fetching worklog types in staff service');
449
+ return Promise.reject(error);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Delete all registers related to the shift in the time tracking database.
455
+ * @throws {Error} error
456
+ * @returns {Promise<boolean>} true => if the registers were deleted successfully
457
+ */
458
+
459
+ async deleteShiftRegisters() {
460
+ try {
461
+ Crashlytics.log('user delete shift registers');
462
+ this._deleteShiftData();
463
+ this._deleteCurrentWorkLogData();
464
+ OfflineData.deleteAll();
465
+ return await TimeTracker.deleteAllEvents();
466
+ } catch (error) {
467
+ Crashlytics.recordError(error, 'Error deleting registers from shift tracking database');
468
+ return Promise.reject(error);
469
+ }
470
+ }
471
+
472
+ _deleteShiftData() {
473
+ Storage.delete(SHIFT_ID);
474
+ Storage.delete(SHIFT_STATUS);
475
+ Storage.delete(SHIFT_DATA);
476
+ }
477
+
478
+ _deleteCurrentWorkLogData() {
479
+ Storage.delete(CURRENT_WORKLOG_ID);
480
+ Storage.delete(CURRENT_WORKLOG_DATA);
481
+ }
482
+
483
+ /**
484
+ * @private
485
+ * Start id tracking in the time tracking database.
486
+ * @param {Object} params
487
+ * @param {string} params.id => ID related to the shift
488
+ * @param {string} params.date => Date related to the shift
489
+ * @param {Object} params.payload => Payload related to the shift
490
+ */
491
+
492
+ async _startTracking(params) {
493
+ const {id, date, payload} = params;
494
+ await TimeTracker.addEvent({
495
+ id,
496
+ time: date || new Date().toISOString(),
497
+ type: 'start',
498
+ payload,
499
+ }).catch(() => null);
500
+ }
501
+
502
+ /**
503
+ * @private
504
+ * Finish id tracking in the time tracking database.
505
+ * @param {Object} params
506
+ * @param {string} params.id => ID related to the shift
507
+ * @param {string} params.date => Date related to the shift
508
+ * @param {Object} params.payload => Payload related to the shift
509
+ */
510
+
511
+ async _finishTracking(params) {
512
+ const {id, date, payload} = params;
513
+ await TimeTracker.addEvent({
514
+ id,
515
+ time: date || new Date().toISOString(),
516
+ type: 'finish',
517
+ payload,
518
+ }).catch(() => null);
519
+ }
520
+
521
+ /**
522
+ * @private
523
+ * Extend the shift closing date.
524
+ * @throws {Error} error
525
+ * @returns {Promise<null>} null
526
+ */
527
+
528
+ _extendShiftClosingDate() {
529
+ const shiftData = getObject(SHIFT_DATA);
530
+ const {dateToClose} = shiftData;
531
+ const updatedClosingDate = new Date(dateToClose).getTime() + ONE_HOUR_EXTENSION;
532
+
533
+ shiftData.dateToClose = new Date(updatedClosingDate).toISOString();
534
+
535
+ setObject(SHIFT_DATA, shiftData);
536
+ }
537
+ }
538
+ export default new Shift();
@@ -0,0 +1,92 @@
1
+ import StaffApiServices from './StaffApiServices';
2
+ import TrackerRecords from './TrackerRecords';
3
+ import {isArray, isEmptyArray} from './utils/helpers';
4
+
5
+ class ShiftWorklogs {
6
+ async open(params) {
7
+ try {
8
+ const {referenceId = '', startDate = ''} = params || {};
9
+ const {result} = await StaffApiServices.postWorklog({
10
+ workLogTypeRefId: referenceId,
11
+ startDate,
12
+ });
13
+
14
+ const {itemsCreated = [], itemsUpdated} = result || {};
15
+
16
+ if (itemsCreated?.length) {
17
+ const [createdWorklog = {}] = itemsCreated;
18
+ return createdWorklog?.id;
19
+ }
20
+
21
+ const [updatedWorklog] = itemsUpdated || [];
22
+
23
+ return updatedWorklog?.id;
24
+ } catch (error) {
25
+ return Promise.reject(error);
26
+ }
27
+ }
28
+
29
+ async finish(params) {
30
+ try {
31
+ const {referenceId = '', endDate = ''} = params || {};
32
+ const {result} = await StaffApiServices.postWorklog({
33
+ workLogTypeRefId: referenceId,
34
+ endDate,
35
+ });
36
+
37
+ const {itemsCreated = [], itemsUpdated} = result;
38
+ if (itemsCreated?.length) {
39
+ const [createdWorklog = {}] = itemsCreated;
40
+ return createdWorklog?.id;
41
+ }
42
+
43
+ const [updatedWorklog] = itemsUpdated || [];
44
+
45
+ return updatedWorklog?.id;
46
+ } catch (error) {
47
+ return Promise.reject(error);
48
+ }
49
+ }
50
+
51
+ async getList(shiftId) {
52
+ try {
53
+ const {result: workLogs} = await StaffApiServices.getWorkLogsList({
54
+ filters: {
55
+ shiftId,
56
+ status: ['inProgress', 'finished'],
57
+ },
58
+ });
59
+
60
+ return workLogs || [];
61
+ } catch (error) {
62
+ return Promise.reject(error);
63
+ }
64
+ }
65
+
66
+ async getShiftTrackedWorkLogs(shiftId) {
67
+ try {
68
+ if (!shiftId) throw new Error(`Shift ID is required, but got ${shiftId}`);
69
+
70
+ const workLogEvents = await TrackerRecords.getClientShiftActivities(shiftId);
71
+ const shiftWorkLogs = workLogEvents.filter((e) => e?.payload?.shiftId === shiftId);
72
+ shiftWorkLogs.sort((a, b) => new Date(a?.time) - new Date(b?.time));
73
+
74
+ return shiftWorkLogs;
75
+ } catch (error) {
76
+ return Promise.reject(error);
77
+ }
78
+ }
79
+
80
+ async postPendingBatch(pendingWorkLogs = []) {
81
+ try {
82
+ if (!isArray(pendingWorkLogs) || isEmptyArray(pendingWorkLogs)) return null;
83
+
84
+ await StaffApiServices.postWorklog(pendingWorkLogs);
85
+ return null;
86
+ } catch (error) {
87
+ return Promise.reject(error);
88
+ }
89
+ }
90
+ }
91
+
92
+ export default new ShiftWorklogs();