@hed-hog/operations 0.0.302 → 0.0.304

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 (167) hide show
  1. package/README.md +200 -43
  2. package/dist/controllers/operations-approvals.controller.d.ts +9 -0
  3. package/dist/controllers/operations-approvals.controller.d.ts.map +1 -0
  4. package/dist/controllers/operations-approvals.controller.js +64 -0
  5. package/dist/controllers/operations-approvals.controller.js.map +1 -0
  6. package/dist/controllers/operations-collaborators.controller.d.ts +223 -0
  7. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -0
  8. package/dist/controllers/operations-collaborators.controller.js +96 -0
  9. package/dist/controllers/operations-collaborators.controller.js.map +1 -0
  10. package/dist/controllers/operations-contracts.controller.d.ts +683 -0
  11. package/dist/controllers/operations-contracts.controller.d.ts.map +1 -0
  12. package/dist/controllers/operations-contracts.controller.js +198 -0
  13. package/dist/controllers/operations-contracts.controller.js.map +1 -0
  14. package/dist/controllers/operations-org-structure.controller.d.ts +108 -0
  15. package/dist/controllers/operations-org-structure.controller.d.ts.map +1 -0
  16. package/dist/controllers/operations-org-structure.controller.js +143 -0
  17. package/dist/controllers/operations-org-structure.controller.js.map +1 -0
  18. package/dist/controllers/operations-projects.controller.d.ts +169 -0
  19. package/dist/controllers/operations-projects.controller.d.ts.map +1 -0
  20. package/dist/controllers/operations-projects.controller.js +87 -0
  21. package/dist/controllers/operations-projects.controller.js.map +1 -0
  22. package/dist/controllers/operations-tasks.controller.d.ts +54 -0
  23. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -0
  24. package/dist/controllers/operations-tasks.controller.js +79 -0
  25. package/dist/controllers/operations-tasks.controller.js.map +1 -0
  26. package/dist/controllers/operations-timesheets.controller.d.ts +99 -0
  27. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -0
  28. package/dist/controllers/operations-timesheets.controller.js +154 -0
  29. package/dist/controllers/operations-timesheets.controller.js.map +1 -0
  30. package/dist/dto/create-collaborator-type.dto.d.ts +10 -0
  31. package/dist/dto/create-collaborator-type.dto.d.ts.map +1 -0
  32. package/dist/dto/create-collaborator-type.dto.js +56 -0
  33. package/dist/dto/create-collaborator-type.dto.js.map +1 -0
  34. package/dist/dto/create-collaborator.dto.d.ts +42 -0
  35. package/dist/dto/create-collaborator.dto.d.ts.map +1 -0
  36. package/dist/dto/create-collaborator.dto.js +228 -0
  37. package/dist/dto/create-collaborator.dto.js.map +1 -0
  38. package/dist/dto/create-schedule-adjustment-request.dto.d.ts +17 -0
  39. package/dist/dto/create-schedule-adjustment-request.dto.d.ts.map +1 -0
  40. package/dist/dto/create-schedule-adjustment-request.dto.js +89 -0
  41. package/dist/dto/create-schedule-adjustment-request.dto.js.map +1 -0
  42. package/dist/dto/create-task.dto.d.ts +8 -0
  43. package/dist/dto/create-task.dto.d.ts.map +1 -0
  44. package/dist/dto/create-task.dto.js +50 -0
  45. package/dist/dto/create-task.dto.js.map +1 -0
  46. package/dist/dto/create-time-off-request.dto.d.ts +9 -0
  47. package/dist/dto/create-time-off-request.dto.d.ts.map +1 -0
  48. package/dist/dto/create-time-off-request.dto.js +54 -0
  49. package/dist/dto/create-time-off-request.dto.js.map +1 -0
  50. package/dist/dto/create-timesheet-entry.dto.d.ts +12 -0
  51. package/dist/dto/create-timesheet-entry.dto.d.ts.map +1 -0
  52. package/dist/dto/create-timesheet-entry.dto.js +75 -0
  53. package/dist/dto/create-timesheet-entry.dto.js.map +1 -0
  54. package/dist/dto/list-collaborator-types.dto.d.ts +4 -0
  55. package/dist/dto/list-collaborator-types.dto.d.ts.map +1 -0
  56. package/dist/dto/list-collaborator-types.dto.js +29 -0
  57. package/dist/dto/list-collaborator-types.dto.js.map +1 -0
  58. package/dist/dto/list-collaborators.dto.d.ts +8 -0
  59. package/dist/dto/list-collaborators.dto.d.ts.map +1 -0
  60. package/dist/dto/list-collaborators.dto.js +42 -0
  61. package/dist/dto/list-collaborators.dto.js.map +1 -0
  62. package/dist/dto/list-project-options.dto.d.ts +4 -0
  63. package/dist/dto/list-project-options.dto.d.ts.map +1 -0
  64. package/dist/dto/list-project-options.dto.js +8 -0
  65. package/dist/dto/list-project-options.dto.js.map +1 -0
  66. package/dist/dto/list-tasks.dto.d.ts +7 -0
  67. package/dist/dto/list-tasks.dto.d.ts.map +1 -0
  68. package/dist/dto/list-tasks.dto.js +38 -0
  69. package/dist/dto/list-tasks.dto.js.map +1 -0
  70. package/dist/dto/list-timesheet-entries.dto.d.ts +10 -0
  71. package/dist/dto/list-timesheet-entries.dto.d.ts.map +1 -0
  72. package/dist/dto/list-timesheet-entries.dto.js +54 -0
  73. package/dist/dto/list-timesheet-entries.dto.js.map +1 -0
  74. package/dist/dto/update-collaborator-type.dto.d.ts +4 -0
  75. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -0
  76. package/dist/dto/update-collaborator-type.dto.js +8 -0
  77. package/dist/dto/update-collaborator-type.dto.js.map +1 -0
  78. package/dist/dto/update-collaborator.dto.d.ts +4 -0
  79. package/dist/dto/update-collaborator.dto.d.ts.map +1 -0
  80. package/dist/dto/update-collaborator.dto.js +8 -0
  81. package/dist/dto/update-collaborator.dto.js.map +1 -0
  82. package/dist/dto/update-task.dto.d.ts +8 -0
  83. package/dist/dto/update-task.dto.d.ts.map +1 -0
  84. package/dist/dto/update-task.dto.js +51 -0
  85. package/dist/dto/update-task.dto.js.map +1 -0
  86. package/dist/operations.controller.d.ts +0 -1045
  87. package/dist/operations.controller.d.ts.map +1 -1
  88. package/dist/operations.controller.js +0 -429
  89. package/dist/operations.controller.js.map +1 -1
  90. package/dist/operations.module.d.ts.map +1 -1
  91. package/dist/operations.module.js +23 -2
  92. package/dist/operations.module.js.map +1 -1
  93. package/dist/operations.service.d.ts +373 -8
  94. package/dist/operations.service.d.ts.map +1 -1
  95. package/dist/operations.service.js +1598 -111
  96. package/dist/operations.service.js.map +1 -1
  97. package/dist/operations.service.spec.js +315 -1
  98. package/dist/operations.service.spec.js.map +1 -1
  99. package/dist/services/shared/operations-access.service.d.ts +16 -0
  100. package/dist/services/shared/operations-access.service.d.ts.map +1 -0
  101. package/dist/services/shared/operations-access.service.js +48 -0
  102. package/dist/services/shared/operations-access.service.js.map +1 -0
  103. package/hedhog/data/dashboard.yaml +20 -0
  104. package/hedhog/data/dashboard_component.yaml +274 -0
  105. package/hedhog/data/dashboard_component_role.yaml +174 -0
  106. package/hedhog/data/dashboard_item.yaml +299 -0
  107. package/hedhog/data/dashboard_role.yaml +20 -0
  108. package/hedhog/data/menu.yaml +30 -13
  109. package/hedhog/data/operations_collaborator_type.yaml +76 -0
  110. package/hedhog/data/route.yaml +183 -0
  111. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +231 -0
  112. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +134 -49
  113. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +772 -93
  114. package/hedhog/frontend/app/_components/department-select-with-create.tsx.ejs +38 -16
  115. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +875 -632
  116. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +213 -0
  117. package/hedhog/frontend/app/_lib/api.ts.ejs +30 -1
  118. package/hedhog/frontend/app/_lib/types.ts.ejs +142 -39
  119. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +33 -2
  120. package/hedhog/frontend/app/approvals/page.tsx.ejs +116 -98
  121. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -0
  122. package/hedhog/frontend/app/collaborators/page.tsx.ejs +109 -68
  123. package/hedhog/frontend/app/contracts/page.tsx.ejs +99 -102
  124. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +98 -102
  125. package/hedhog/frontend/app/departments/page.tsx.ejs +96 -75
  126. package/hedhog/frontend/app/projects/page.tsx.ejs +137 -127
  127. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +244 -120
  128. package/hedhog/frontend/app/team/page.tsx.ejs +15 -2
  129. package/hedhog/frontend/app/time-off/page.tsx.ejs +158 -82
  130. package/hedhog/frontend/app/timesheets/page.tsx.ejs +814 -357
  131. package/hedhog/frontend/messages/en.json +243 -51
  132. package/hedhog/frontend/messages/pt.json +458 -268
  133. package/hedhog/table/operations_collaborator.yaml +26 -13
  134. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -0
  135. package/hedhog/table/operations_collaborator_type.yaml +33 -0
  136. package/hedhog/table/operations_job_title.yaml +24 -0
  137. package/hedhog/table/operations_project_assignment.yaml +9 -0
  138. package/hedhog/table/operations_project_role.yaml +39 -0
  139. package/hedhog/table/operations_task.yaml +30 -0
  140. package/hedhog/table/operations_timesheet_entry.yaml +12 -0
  141. package/package.json +6 -6
  142. package/src/controllers/operations-approvals.controller.ts +24 -0
  143. package/src/controllers/operations-collaborators.controller.ts +60 -0
  144. package/src/controllers/operations-contracts.controller.ts +138 -0
  145. package/src/controllers/operations-org-structure.controller.ts +92 -0
  146. package/src/controllers/operations-projects.controller.ts +50 -0
  147. package/src/controllers/operations-tasks.controller.ts +52 -0
  148. package/src/controllers/operations-timesheets.controller.ts +100 -0
  149. package/src/dto/create-collaborator-type.dto.ts +43 -0
  150. package/src/dto/create-collaborator.dto.ts +223 -0
  151. package/src/dto/create-schedule-adjustment-request.dto.ts +91 -0
  152. package/src/dto/create-task.dto.ts +35 -0
  153. package/src/dto/create-time-off-request.dto.ts +53 -0
  154. package/src/dto/create-timesheet-entry.dto.ts +67 -0
  155. package/src/dto/list-collaborator-types.dto.ts +15 -0
  156. package/src/dto/list-collaborators.dto.ts +30 -0
  157. package/src/dto/list-project-options.dto.ts +3 -0
  158. package/src/dto/list-tasks.dto.ts +25 -0
  159. package/src/dto/list-timesheet-entries.dto.ts +40 -0
  160. package/src/dto/update-collaborator-type.dto.ts +3 -0
  161. package/src/dto/update-collaborator.dto.ts +3 -0
  162. package/src/dto/update-task.dto.ts +36 -0
  163. package/src/operations.controller.ts +1 -278
  164. package/src/operations.module.ts +23 -2
  165. package/src/operations.service.spec.ts +450 -0
  166. package/src/operations.service.ts +4641 -2163
  167. package/src/services/shared/operations-access.service.ts +52 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { BadRequestException } from '@nestjs/common';
4
4
  import { OperationsService } from './operations.service';
5
+ import { OperationsAccessService } from './services/shared/operations-access.service';
5
6
 
6
7
  describe('OperationsService proposal integration', () => {
7
8
  let service: OperationsService;
@@ -23,6 +24,7 @@ describe('OperationsService proposal integration', () => {
23
24
  integrationApi as any,
24
25
  {} as any,
25
26
  {} as any,
27
+ new OperationsAccessService(),
26
28
  );
27
29
 
28
30
  jest.spyOn(service as any, 'generateContractCode').mockResolvedValue('CTR-001');
@@ -208,3 +210,451 @@ describe('OperationsService proposal integration', () => {
208
210
  );
209
211
  });
210
212
  });
213
+
214
+ describe('OperationsService quick-entry timesheets', () => {
215
+ let service: OperationsService;
216
+
217
+ beforeEach(() => {
218
+ service = new OperationsService(
219
+ {
220
+ $transaction: jest.fn(),
221
+ } as any,
222
+ {} as any,
223
+ {
224
+ publishEvent: jest.fn().mockResolvedValue(undefined),
225
+ } as any,
226
+ {} as any,
227
+ {} as any,
228
+ new OperationsAccessService(),
229
+ );
230
+
231
+ jest.spyOn(service as any, 'getActorContext').mockResolvedValue({
232
+ userId: 15,
233
+ roleSlugs: ['admin-operations-collaborator'],
234
+ collaboratorId: 7,
235
+ collaboratorName: 'Taylor Tester',
236
+ isDirector: false,
237
+ isSupervisor: false,
238
+ isCollaborator: true,
239
+ teamCollaboratorIds: [],
240
+ visibleCollaboratorIds: [7],
241
+ visibleProjectIds: [11],
242
+ });
243
+ });
244
+
245
+ afterEach(() => {
246
+ jest.restoreAllMocks();
247
+ });
248
+
249
+ it('rejects quick entries with non-positive duration', async () => {
250
+ await expect(
251
+ service.createTimesheetEntry(15, {
252
+ projectId: 11,
253
+ workDate: '2026-04-09',
254
+ duration: 0,
255
+ unit: 'minutes',
256
+ } as any),
257
+ ).rejects.toThrow(BadRequestException);
258
+ });
259
+
260
+ it('rejects quick entries without a work date', async () => {
261
+ await expect(
262
+ service.createTimesheetEntry(15, {
263
+ projectId: 11,
264
+ duration: 30,
265
+ unit: 'minutes',
266
+ } as any),
267
+ ).rejects.toThrow(BadRequestException);
268
+ });
269
+
270
+ it('stores quick-entry duration in minutes and keeps hours compatible', async () => {
271
+ const tx = {
272
+ $queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 91 }]),
273
+ };
274
+
275
+ (service as any).prisma.$transaction.mockImplementation(
276
+ async (callback: (client: unknown) => unknown) => callback(tx),
277
+ );
278
+
279
+ jest.spyOn(service as any, 'resolveOwnedProjectAssignment').mockResolvedValue({
280
+ id: 33,
281
+ projectId: 11,
282
+ projectName: 'Project Atlas',
283
+ projectCode: 'OPS-11',
284
+ roleLabel: 'Engineer',
285
+ });
286
+ jest.spyOn(service as any, 'getOwnedTaskRecord').mockResolvedValue({
287
+ id: 44,
288
+ name: 'Implement API',
289
+ projectAssignmentId: 33,
290
+ projectId: 11,
291
+ projectName: 'Project Atlas',
292
+ projectCode: 'OPS-11',
293
+ });
294
+ jest.spyOn(service as any, 'getOrCreateTimesheetForWorkDate').mockResolvedValue(55);
295
+ jest.spyOn(service as any, 'refreshTimesheetTotal').mockResolvedValue(undefined);
296
+ jest.spyOn(service as any, 'getTimesheetEntryByIdForActor').mockResolvedValue({
297
+ id: 91,
298
+ durationMinutes: 90,
299
+ hours: 1.5,
300
+ });
301
+
302
+ const result = await service.createTimesheetEntry(15, {
303
+ projectId: 11,
304
+ taskId: 44,
305
+ workDate: '2026-04-09',
306
+ duration: 1.5,
307
+ unit: 'hours',
308
+ description: 'Worked on the API layer',
309
+ } as any);
310
+
311
+ expect(result).toEqual(
312
+ expect.objectContaining({
313
+ id: 91,
314
+ durationMinutes: 90,
315
+ hours: 1.5,
316
+ }),
317
+ );
318
+ expect(tx.$queryRawUnsafe).toHaveBeenCalledWith(
319
+ expect.stringContaining('INSERT INTO operations_timesheet_entry'),
320
+ 55,
321
+ 33,
322
+ 44,
323
+ 'Implement API',
324
+ '2026-04-09',
325
+ 90,
326
+ 1.5,
327
+ 'Worked on the API layer',
328
+ );
329
+ });
330
+
331
+ it('uses the task assignment when full timesheet entries omit projectAssignmentId', async () => {
332
+ const tx = {
333
+ $queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 55 }]),
334
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
335
+ };
336
+
337
+ (service as any).prisma.$transaction.mockImplementation(
338
+ async (callback: (client: unknown) => unknown) => callback(tx),
339
+ );
340
+
341
+ jest.spyOn(service as any, 'getCollaboratorById').mockResolvedValue({
342
+ id: 7,
343
+ supervisorId: 18,
344
+ });
345
+ jest.spyOn(service as any, 'getOwnedTaskRecord').mockResolvedValue({
346
+ id: 44,
347
+ name: 'Implement API',
348
+ projectAssignmentId: 33,
349
+ projectId: 11,
350
+ projectName: 'Project Atlas',
351
+ projectCode: 'OPS-11',
352
+ });
353
+ jest.spyOn(service as any, 'refreshTimesheetTotal').mockResolvedValue(undefined);
354
+ jest.spyOn(service as any, 'listSingleTimesheet').mockResolvedValue({
355
+ id: 55,
356
+ status: 'draft',
357
+ });
358
+
359
+ await service.createTimesheet(15, {
360
+ weekStartDate: '2026-04-06',
361
+ weekEndDate: '2026-04-12',
362
+ entries: [
363
+ {
364
+ taskId: 44,
365
+ workDate: '2026-04-09',
366
+ duration: 2,
367
+ unit: 'hours',
368
+ description: 'Worked on the API layer',
369
+ },
370
+ ],
371
+ } as any);
372
+
373
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
374
+ expect.stringContaining('INSERT INTO operations_timesheet_entry'),
375
+ 55,
376
+ 33,
377
+ 44,
378
+ 'Implement API',
379
+ '2026-04-09',
380
+ 120,
381
+ 2,
382
+ 'Worked on the API layer',
383
+ );
384
+ });
385
+
386
+ it('rejects submitting a timesheet without a resolved approver', async () => {
387
+ const tx = {
388
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
389
+ $queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 1 }]),
390
+ };
391
+
392
+ (service as any).prisma.$transaction.mockImplementation(
393
+ async (callback: (client: unknown) => unknown) => callback(tx),
394
+ );
395
+
396
+ jest.spyOn(service as any, 'getTimesheetById').mockResolvedValue({
397
+ id: 55,
398
+ collaboratorId: 7,
399
+ approverCollaboratorId: null,
400
+ status: 'draft',
401
+ });
402
+ jest.spyOn(service as any, 'getCollaboratorById').mockResolvedValue({
403
+ id: 7,
404
+ supervisorId: null,
405
+ });
406
+ jest.spyOn(service as any, 'upsertApproval').mockResolvedValue(undefined);
407
+ jest.spyOn(service as any, 'listSingleTimesheet').mockResolvedValue({
408
+ id: 55,
409
+ status: 'submitted',
410
+ });
411
+
412
+ await expect(service.submitTimesheet(15, 55)).rejects.toThrow(BadRequestException);
413
+ expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
414
+ });
415
+
416
+ it('rejects submitting a timesheet without active entries', async () => {
417
+ const tx = {
418
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
419
+ $queryRawUnsafe: jest.fn().mockResolvedValue([]),
420
+ };
421
+
422
+ (service as any).prisma.$transaction.mockImplementation(
423
+ async (callback: (client: unknown) => unknown) => callback(tx),
424
+ );
425
+
426
+ jest.spyOn(service as any, 'getTimesheetById').mockResolvedValue({
427
+ id: 56,
428
+ collaboratorId: 7,
429
+ approverCollaboratorId: 18,
430
+ status: 'draft',
431
+ });
432
+ jest.spyOn(service as any, 'getCollaboratorById').mockResolvedValue({
433
+ id: 7,
434
+ supervisorId: 18,
435
+ });
436
+ jest.spyOn(service as any, 'querySingle').mockResolvedValue({ exists: false });
437
+ jest.spyOn(service as any, 'upsertApproval').mockResolvedValue(undefined);
438
+ jest.spyOn(service as any, 'listSingleTimesheet').mockResolvedValue({
439
+ id: 56,
440
+ status: 'submitted',
441
+ });
442
+
443
+ await expect(service.submitTimesheet(15, 56)).rejects.toThrow(BadRequestException);
444
+ expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
445
+ });
446
+
447
+ it('deletes only draft quick entries and refreshes the weekly total', async () => {
448
+ const tx = {
449
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
450
+ };
451
+
452
+ (service as any).prisma.$transaction.mockImplementation(
453
+ async (callback: (client: unknown) => unknown) => callback(tx),
454
+ );
455
+
456
+ jest.spyOn(service as any, 'getTimesheetEntryByIdForActor').mockResolvedValue({
457
+ id: 91,
458
+ timesheetId: 55,
459
+ collaboratorId: 7,
460
+ status: 'draft',
461
+ });
462
+ jest.spyOn(service as any, 'refreshTimesheetTotal').mockResolvedValue(undefined);
463
+
464
+ await expect(service.removeTimesheetEntry(15, 91)).resolves.toEqual({
465
+ success: true,
466
+ });
467
+
468
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
469
+ expect.stringContaining('UPDATE operations_timesheet_entry'),
470
+ 91,
471
+ );
472
+ expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 55);
473
+ });
474
+
475
+ it('rejects deleting entries from submitted timesheets', async () => {
476
+ jest.spyOn(service as any, 'getTimesheetEntryByIdForActor').mockResolvedValue({
477
+ id: 92,
478
+ timesheetId: 56,
479
+ collaboratorId: 7,
480
+ status: 'submitted',
481
+ });
482
+
483
+ await expect(service.removeTimesheetEntry(15, 92)).rejects.toThrow(
484
+ BadRequestException,
485
+ );
486
+ });
487
+ });
488
+
489
+ describe('OperationsService approval side effects', () => {
490
+ let service: OperationsService;
491
+
492
+ beforeEach(() => {
493
+ service = new OperationsService(
494
+ {
495
+ $transaction: jest.fn(),
496
+ } as any,
497
+ {} as any,
498
+ {
499
+ publishEvent: jest.fn().mockResolvedValue(undefined),
500
+ } as any,
501
+ {} as any,
502
+ {} as any,
503
+ new OperationsAccessService(),
504
+ );
505
+
506
+ jest.spyOn(service as any, 'getActorContext').mockResolvedValue({
507
+ userId: 22,
508
+ roleSlugs: ['admin-operations-supervisor'],
509
+ collaboratorId: 18,
510
+ collaboratorName: 'Sam Supervisor',
511
+ isDirector: false,
512
+ isSupervisor: true,
513
+ isCollaborator: false,
514
+ teamCollaboratorIds: [7],
515
+ visibleCollaboratorIds: [7, 18],
516
+ visibleProjectIds: [11],
517
+ });
518
+ });
519
+
520
+ afterEach(() => {
521
+ jest.restoreAllMocks();
522
+ });
523
+
524
+ it('applies approved permanent schedule adjustments to the active collaborator weekly schedule', async () => {
525
+ const tx = {
526
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
527
+ $queryRawUnsafe: jest
528
+ .fn()
529
+ .mockResolvedValueOnce([
530
+ {
531
+ collaboratorId: 7,
532
+ requestScope: 'permanent',
533
+ },
534
+ ])
535
+ .mockResolvedValueOnce([
536
+ {
537
+ weekday: 'monday',
538
+ isWorkingDay: true,
539
+ startTime: '08:00',
540
+ endTime: '17:00',
541
+ breakMinutes: 45,
542
+ },
543
+ {
544
+ weekday: 'friday',
545
+ isWorkingDay: false,
546
+ startTime: null,
547
+ endTime: null,
548
+ breakMinutes: 0,
549
+ },
550
+ ]),
551
+ };
552
+
553
+ (service as any).prisma.$transaction.mockImplementation(
554
+ async (callback: (client: unknown) => unknown) => callback(tx),
555
+ );
556
+
557
+ jest.spyOn(service as any, 'querySingle')
558
+ .mockResolvedValueOnce({
559
+ id: 81,
560
+ targetType: 'schedule_adjustment_request',
561
+ targetId: 400,
562
+ requesterCollaboratorId: 7,
563
+ approverCollaboratorId: 18,
564
+ status: 'pending',
565
+ })
566
+ .mockResolvedValueOnce({
567
+ id: 81,
568
+ targetType: 'schedule_adjustment_request',
569
+ targetId: 400,
570
+ status: 'approved',
571
+ decidedAt: '2026-04-11T12:00:00.000Z',
572
+ decisionNote: 'Approved for the new weekly schedule.',
573
+ });
574
+ jest.spyOn(service as any, 'insertApprovalHistory').mockResolvedValue(undefined);
575
+
576
+ await expect(
577
+ service.approve(22, 81, {
578
+ note: 'Approved for the new weekly schedule.',
579
+ }),
580
+ ).resolves.toEqual(
581
+ expect.objectContaining({
582
+ id: 81,
583
+ status: 'approved',
584
+ }),
585
+ );
586
+
587
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
588
+ expect.stringContaining('UPDATE operations_schedule_adjustment_request'),
589
+ 'approved',
590
+ 18,
591
+ 400,
592
+ );
593
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
594
+ expect.stringContaining('UPDATE operations_collaborator_schedule_day'),
595
+ 7,
596
+ );
597
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
598
+ expect.stringContaining('INSERT INTO operations_collaborator_schedule_day'),
599
+ 7,
600
+ 'monday',
601
+ true,
602
+ '08:00',
603
+ '17:00',
604
+ 45,
605
+ );
606
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
607
+ expect.stringContaining('INSERT INTO operations_collaborator_schedule_day'),
608
+ 7,
609
+ 'friday',
610
+ false,
611
+ null,
612
+ null,
613
+ 0,
614
+ );
615
+ });
616
+
617
+ it('keeps the collaborator weekly schedule unchanged for approved temporary adjustments', async () => {
618
+ const tx = {
619
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
620
+ $queryRawUnsafe: jest.fn().mockResolvedValueOnce([
621
+ {
622
+ collaboratorId: 7,
623
+ requestScope: 'temporary',
624
+ },
625
+ ]),
626
+ };
627
+
628
+ (service as any).prisma.$transaction.mockImplementation(
629
+ async (callback: (client: unknown) => unknown) => callback(tx),
630
+ );
631
+
632
+ jest.spyOn(service as any, 'querySingle')
633
+ .mockResolvedValueOnce({
634
+ id: 82,
635
+ targetType: 'schedule_adjustment_request',
636
+ targetId: 401,
637
+ requesterCollaboratorId: 7,
638
+ approverCollaboratorId: 18,
639
+ status: 'pending',
640
+ })
641
+ .mockResolvedValueOnce({
642
+ id: 82,
643
+ targetType: 'schedule_adjustment_request',
644
+ targetId: 401,
645
+ status: 'approved',
646
+ decidedAt: '2026-04-11T12:05:00.000Z',
647
+ decisionNote: 'Approved as a temporary exception.',
648
+ });
649
+ jest.spyOn(service as any, 'insertApprovalHistory').mockResolvedValue(undefined);
650
+
651
+ await service.approve(22, 82, {
652
+ note: 'Approved as a temporary exception.',
653
+ });
654
+
655
+ expect(tx.$executeRawUnsafe).not.toHaveBeenCalledWith(
656
+ expect.stringContaining('UPDATE operations_collaborator_schedule_day'),
657
+ 7,
658
+ );
659
+ });
660
+ });