@hed-hog/operations 0.0.332 → 0.0.338

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 (109) hide show
  1. package/dist/controllers/operations-collaborators.controller.d.ts +0 -54
  2. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-collaborators.controller.js +0 -100
  4. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  5. package/dist/controllers/operations-contracts.controller.d.ts +12 -12
  6. package/dist/operations.service.d.ts +0 -76
  7. package/dist/operations.service.d.ts.map +1 -1
  8. package/dist/operations.service.js +7 -230
  9. package/dist/operations.service.js.map +1 -1
  10. package/dist/operations.service.spec.js +6 -0
  11. package/dist/operations.service.spec.js.map +1 -1
  12. package/hedhog/data/menu.yaml +8 -27
  13. package/hedhog/data/route.yaml +0 -72
  14. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +476 -0
  15. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +3 -39
  16. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +261 -0
  17. package/hedhog/frontend/app/_components/collaborator-tasks-tab.tsx.ejs +358 -358
  18. package/hedhog/frontend/app/_components/collaborator-timesheets-tab.tsx.ejs +6 -6
  19. package/hedhog/frontend/app/_components/contract-content-editor.tsx.ejs +258 -0
  20. package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +5 -4
  21. package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +1 -0
  22. package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +10 -218
  23. package/hedhog/frontend/app/_components/project-cost-report-screen.tsx.ejs +23 -23
  24. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +24 -708
  25. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +38 -158
  26. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +1 -5
  27. package/hedhog/frontend/app/_components/task-form-sheet.tsx.ejs +629 -629
  28. package/hedhog/frontend/app/_lib/api.ts.ejs +0 -151
  29. package/hedhog/frontend/app/_lib/types.ts.ejs +0 -1
  30. package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +0 -18
  31. package/hedhog/frontend/app/my-projects/page.tsx.ejs +2 -16
  32. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +86 -24
  33. package/hedhog/frontend/app/projects/page.tsx.ejs +6 -42
  34. package/hedhog/frontend/messages/en.json +2 -96
  35. package/hedhog/frontend/messages/operations/operations/en.json +2100 -0
  36. package/hedhog/frontend/messages/operations/operations/pt.json +2111 -0
  37. package/hedhog/frontend/messages/pt.json +2 -96
  38. package/hedhog/frontend/widgets/capacity-distribution.tsx.ejs +16 -16
  39. package/hedhog/frontend/widgets/effort-by-project.tsx.ejs +16 -16
  40. package/hedhog/frontend/widgets/headcount-by-area.tsx.ejs +16 -16
  41. package/hedhog/frontend/widgets/index.ts.ejs +25 -25
  42. package/hedhog/frontend/widgets/managed-projects-status.tsx.ejs +16 -16
  43. package/hedhog/frontend/widgets/my-hours-period-kpi.tsx.ejs +16 -16
  44. package/hedhog/frontend/widgets/my-open-requests-kpi.tsx.ejs +16 -16
  45. package/hedhog/frontend/widgets/my-pending-requests-list.tsx.ejs +16 -16
  46. package/hedhog/frontend/widgets/my-project-allocations-kpi.tsx.ejs +16 -16
  47. package/hedhog/frontend/widgets/my-quick-actions.tsx.ejs +16 -16
  48. package/hedhog/frontend/widgets/my-relevant-deadlines.tsx.ejs +16 -16
  49. package/hedhog/frontend/widgets/my-timesheet-status-kpi.tsx.ejs +16 -16
  50. package/hedhog/frontend/widgets/my-weekly-journey.tsx.ejs +16 -16
  51. package/hedhog/frontend/widgets/portfolio-costs-kpi.tsx.ejs +16 -16
  52. package/hedhog/frontend/widgets/portfolio-effort-kpi.tsx.ejs +16 -16
  53. package/hedhog/frontend/widgets/portfolio-projects-kpi.tsx.ejs +16 -16
  54. package/hedhog/frontend/widgets/portfolio-risk-kpi.tsx.ejs +16 -16
  55. package/hedhog/frontend/widgets/project-status-overview.tsx.ejs +16 -16
  56. package/hedhog/frontend/widgets/shared-operations-widget.tsx.ejs +169 -169
  57. package/hedhog/frontend/widgets/strategic-deadlines.tsx.ejs +16 -16
  58. package/hedhog/frontend/widgets/team-approval-queue.tsx.ejs +16 -16
  59. package/hedhog/frontend/widgets/team-capacity-kpi.tsx.ejs +16 -16
  60. package/hedhog/frontend/widgets/team-headcount-kpi.tsx.ejs +16 -16
  61. package/hedhog/frontend/widgets/team-hours-kpi.tsx.ejs +16 -16
  62. package/hedhog/frontend/widgets/team-pending-approvals-kpi.tsx.ejs +16 -16
  63. package/hedhog/frontend/widgets/team-utilization-overview.tsx.ejs +16 -16
  64. package/hedhog/frontend/widgets/team-workload-alerts.tsx.ejs +16 -16
  65. package/hedhog/table/operations_collaborator.yaml +8 -8
  66. package/hedhog/table/operations_task.yaml +76 -76
  67. package/hedhog/table/operations_task_activity.yaml +51 -51
  68. package/package.json +6 -6
  69. package/src/controllers/operations-collaborators.controller.ts +8 -117
  70. package/src/controllers/operations-tasks.controller.ts +156 -156
  71. package/src/dashboard/widgets/MyQuickActions.tsx +22 -22
  72. package/src/dto/create-collaborator.dto.ts +4 -4
  73. package/src/operations.service.spec.ts +1006 -988
  74. package/src/operations.service.ts +7 -323
  75. package/dist/dto/create-collaborator-invoice.dto.d.ts +0 -11
  76. package/dist/dto/create-collaborator-invoice.dto.d.ts.map +0 -1
  77. package/dist/dto/create-collaborator-invoice.dto.js +0 -55
  78. package/dist/dto/create-collaborator-invoice.dto.js.map +0 -1
  79. package/dist/dto/create-collaborator-payment.dto.d.ts +0 -10
  80. package/dist/dto/create-collaborator-payment.dto.d.ts.map +0 -1
  81. package/dist/dto/create-collaborator-payment.dto.js +0 -50
  82. package/dist/dto/create-collaborator-payment.dto.js.map +0 -1
  83. package/dist/dto/list-collaborator-invoice.dto.d.ts +0 -4
  84. package/dist/dto/list-collaborator-invoice.dto.d.ts.map +0 -1
  85. package/dist/dto/list-collaborator-invoice.dto.js +0 -8
  86. package/dist/dto/list-collaborator-invoice.dto.js.map +0 -1
  87. package/dist/dto/list-collaborator-payment.dto.d.ts +0 -4
  88. package/dist/dto/list-collaborator-payment.dto.d.ts.map +0 -1
  89. package/dist/dto/list-collaborator-payment.dto.js +0 -8
  90. package/dist/dto/list-collaborator-payment.dto.js.map +0 -1
  91. package/dist/dto/update-collaborator-invoice.dto.d.ts +0 -6
  92. package/dist/dto/update-collaborator-invoice.dto.d.ts.map +0 -1
  93. package/dist/dto/update-collaborator-invoice.dto.js +0 -9
  94. package/dist/dto/update-collaborator-invoice.dto.js.map +0 -1
  95. package/dist/dto/update-collaborator-payment.dto.d.ts +0 -6
  96. package/dist/dto/update-collaborator-payment.dto.d.ts.map +0 -1
  97. package/dist/dto/update-collaborator-payment.dto.js +0 -9
  98. package/dist/dto/update-collaborator-payment.dto.js.map +0 -1
  99. package/hedhog/frontend/app/_components/collaborator-invoices-tab.tsx.ejs +0 -443
  100. package/hedhog/frontend/app/_components/collaborator-payment-history-tab.tsx.ejs +0 -429
  101. package/hedhog/frontend/app/tasks-gantt/page.tsx.ejs +0 -953
  102. package/hedhog/table/operations_collaborator_invoice.yaml +0 -35
  103. package/hedhog/table/operations_collaborator_payment.yaml +0 -32
  104. package/src/dto/create-collaborator-invoice.dto.ts +0 -39
  105. package/src/dto/create-collaborator-payment.dto.ts +0 -35
  106. package/src/dto/list-collaborator-invoice.dto.ts +0 -3
  107. package/src/dto/list-collaborator-payment.dto.ts +0 -3
  108. package/src/dto/update-collaborator-invoice.dto.ts +0 -6
  109. package/src/dto/update-collaborator-payment.dto.ts +0 -6
@@ -1,988 +1,1006 @@
1
- /// <reference types="jest" />
2
-
3
- import { BadRequestException } from "@nestjs/common";
4
- import { OperationsService } from "./operations.service";
5
- import { OperationsAccessService } from "./services/shared/operations-access.service";
6
-
7
- describe("OperationsService proposal integration", () => {
8
- let service: OperationsService;
9
- let prisma: { $transaction: jest.Mock };
10
- let integrationApi: { publishEvent: jest.Mock };
11
-
12
- beforeEach(() => {
13
- prisma = {
14
- $transaction: jest.fn(),
15
- };
16
-
17
- integrationApi = {
18
- publishEvent: jest.fn().mockResolvedValue(undefined),
19
- };
20
-
21
- service = new OperationsService(
22
- prisma as any,
23
- {} as any,
24
- integrationApi as any,
25
- {} as any,
26
- {} as any,
27
- new OperationsAccessService(),
28
- );
29
-
30
- jest
31
- .spyOn(service as any, "generateContractCode")
32
- .mockResolvedValue("CTR-001");
33
- jest
34
- .spyOn(service as any, "replaceContractParties")
35
- .mockResolvedValue(undefined);
36
- jest
37
- .spyOn(service as any, "insertContractHistory")
38
- .mockResolvedValue(undefined);
39
- });
40
-
41
- afterEach(() => {
42
- jest.restoreAllMocks();
43
- });
44
-
45
- it("throws when proposalId is missing", async () => {
46
- await expect(
47
- service.createContractFromProposalIntegration({}),
48
- ).rejects.toThrow(BadRequestException);
49
- });
50
-
51
- it("returns the existing contract when the CRM proposal was already converted", async () => {
52
- const tx = {
53
- $queryRawUnsafe: jest
54
- .fn()
55
- .mockResolvedValueOnce([])
56
- .mockResolvedValueOnce([{ id: 42, code: "CTR-EXISTING" }]),
57
- };
58
-
59
- prisma.$transaction.mockImplementation(
60
- async (callback: (client: unknown) => unknown) => callback(tx),
61
- );
62
-
63
- const result = await service.createContractFromProposalIntegration({
64
- proposalId: 1001,
65
- title: "Already converted proposal",
66
- });
67
-
68
- expect(result).toEqual({
69
- id: 42,
70
- code: "CTR-EXISTING",
71
- });
72
- expect((service as any).generateContractCode).not.toHaveBeenCalled();
73
- expect((service as any).replaceContractParties).not.toHaveBeenCalled();
74
- expect(integrationApi.publishEvent).not.toHaveBeenCalled();
75
- });
76
-
77
- it("creates a draft contract with CRM origin metadata and emits an event", async () => {
78
- const tx = {
79
- $queryRawUnsafe: jest
80
- .fn()
81
- .mockResolvedValueOnce([])
82
- .mockResolvedValueOnce([])
83
- .mockResolvedValueOnce([{ id: 77 }]),
84
- };
85
-
86
- prisma.$transaction.mockImplementation(
87
- async (callback: (client: unknown) => unknown) => callback(tx),
88
- );
89
-
90
- const payload = {
91
- proposalId: 1001,
92
- proposalRevisionId: 21,
93
- personId: 5,
94
- approvedByUserId: 9,
95
- correlationId: "proposal:1001",
96
- code: "P-1001",
97
- title: "Implementation Proposal",
98
- total: 1234,
99
- currency: "USD",
100
- locale: "en",
101
- commercialTerms: {
102
- contractCategory: "client" as const,
103
- contractType: "service_agreement" as const,
104
- billingModel: "monthly_retainer" as const,
105
- validFrom: "2025-01-10",
106
- validUntil: "2025-12-31",
107
- notes: "Annual support agreement",
108
- },
109
- person: {
110
- id: 5,
111
- name: "Acme Contact",
112
- tradeName: "Acme Ltd",
113
- email: "ops@acme.test",
114
- phone: "555-0100",
115
- document: "12.345.678/0001-90",
116
- },
117
- revision: {
118
- id: 21,
119
- title: "Revision 1",
120
- summary: "Approved commercial terms",
121
- contentHtml: "<p>draft</p>",
122
- },
123
- items: [
124
- {
125
- name: "Monthly retainer",
126
- description: "Support hours",
127
- amount: 1000,
128
- recurrence: "monthly" as const,
129
- },
130
- {
131
- name: "Setup fee",
132
- amount: 234,
133
- recurrence: "one_time" as const,
134
- },
135
- ],
136
- };
137
-
138
- const result = await service.createContractFromProposalIntegration(payload);
139
-
140
- expect(result).toEqual({
141
- id: 77,
142
- code: "CTR-001",
143
- });
144
-
145
- const insertCall = tx.$queryRawUnsafe.mock.calls[2];
146
- expect(insertCall[0]).toContain("INSERT INTO operations_contract");
147
- expect(insertCall).toContain("crm_proposal");
148
- expect(insertCall).toContain("1001");
149
-
150
- expect((service as any).replaceContractParties).toHaveBeenCalledWith(
151
- tx,
152
- 77,
153
- [
154
- expect.objectContaining({
155
- partyRole: "client",
156
- displayName: "Acme Ltd",
157
- isPrimary: true,
158
- }),
159
- ],
160
- );
161
- expect((service as any).insertContractHistory).toHaveBeenCalledWith(
162
- tx,
163
- 77,
164
- 9,
165
- "created",
166
- "Draft contract generated from CRM proposal P-1001.",
167
- expect.any(String),
168
- );
169
-
170
- expect(integrationApi.publishEvent).toHaveBeenCalledWith(
171
- expect.objectContaining({
172
- eventName: "operations.contract.created",
173
- payload: expect.objectContaining({
174
- proposalId: 1001,
175
- contract: expect.objectContaining({
176
- id: 77,
177
- originType: "crm_proposal",
178
- originId: "1001",
179
- contractType: "service_agreement",
180
- }),
181
- }),
182
- }),
183
- expect.objectContaining({
184
- persistenceClient: tx,
185
- }),
186
- );
187
- });
188
- });
189
-
190
- describe("OperationsService quick-entry timesheets", () => {
191
- let service: OperationsService;
192
-
193
- beforeEach(() => {
194
- service = new OperationsService(
195
- {
196
- $transaction: jest.fn(),
197
- } as any,
198
- {} as any,
199
- {
200
- publishEvent: jest.fn().mockResolvedValue(undefined),
201
- } as any,
202
- {} as any,
203
- {} as any,
204
- new OperationsAccessService(),
205
- );
206
-
207
- jest.spyOn(service as any, "getActorContext").mockResolvedValue({
208
- userId: 15,
209
- roleSlugs: ["operations-collaborator"],
210
- collaboratorId: 7,
211
- collaboratorName: "Taylor Tester",
212
- isDirector: false,
213
- isSupervisor: false,
214
- isCollaborator: true,
215
- teamCollaboratorIds: [],
216
- visibleCollaboratorIds: [7],
217
- visibleProjectIds: [11],
218
- });
219
- });
220
-
221
- afterEach(() => {
222
- jest.restoreAllMocks();
223
- });
224
-
225
- it("rejects quick entries with non-positive duration", async () => {
226
- await expect(
227
- service.createTimesheetEntry(15, {
228
- projectId: 11,
229
- workDate: "2026-04-09",
230
- duration: 0,
231
- unit: "minutes",
232
- } as any),
233
- ).rejects.toThrow(BadRequestException);
234
- });
235
-
236
- it("rejects quick entries without a work date", async () => {
237
- await expect(
238
- service.createTimesheetEntry(15, {
239
- projectId: 11,
240
- duration: 30,
241
- unit: "minutes",
242
- } as any),
243
- ).rejects.toThrow(BadRequestException);
244
- });
245
-
246
- it("stores quick-entry duration in minutes and keeps hours compatible", async () => {
247
- const tx = {
248
- $queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 91 }]),
249
- };
250
-
251
- (service as any).prisma.$transaction.mockImplementation(
252
- async (callback: (client: unknown) => unknown) => callback(tx),
253
- );
254
-
255
- jest
256
- .spyOn(service as any, "resolveOwnedProjectAssignment")
257
- .mockResolvedValue({
258
- id: 33,
259
- projectId: 11,
260
- projectName: "Project Atlas",
261
- projectCode: "OPS-11",
262
- roleLabel: "Engineer",
263
- });
264
- jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
265
- id: 44,
266
- name: "Implement API",
267
- projectAssignmentId: 33,
268
- projectId: 11,
269
- projectName: "Project Atlas",
270
- projectCode: "OPS-11",
271
- });
272
- jest
273
- .spyOn(service as any, "getOrCreateTimesheetForWorkDate")
274
- .mockResolvedValue(55);
275
- jest
276
- .spyOn(service as any, "refreshTimesheetTotal")
277
- .mockResolvedValue(undefined);
278
- jest
279
- .spyOn(service as any, "submitTimesheetForApproval")
280
- .mockResolvedValue(undefined);
281
- jest
282
- .spyOn(service as any, "getTimesheetEntryByIdForActor")
283
- .mockResolvedValue({
284
- id: 91,
285
- durationMinutes: 90,
286
- hours: 1.5,
287
- });
288
-
289
- const result = await service.createTimesheetEntry(15, {
290
- projectId: 11,
291
- taskId: 44,
292
- workDate: "2026-04-09",
293
- duration: 1.5,
294
- unit: "hours",
295
- description: "Worked on the API layer",
296
- } as any);
297
-
298
- expect(result).toEqual(
299
- expect.objectContaining({
300
- id: 91,
301
- durationMinutes: 90,
302
- hours: 1.5,
303
- }),
304
- );
305
- expect(tx.$queryRawUnsafe).toHaveBeenCalledWith(
306
- expect.stringContaining("INSERT INTO operations_timesheet_entry"),
307
- 55,
308
- 33,
309
- 44,
310
- "Implement API",
311
- "2026-04-09",
312
- 90,
313
- 1.5,
314
- "Worked on the API layer",
315
- );
316
- expect((service as any).submitTimesheetForApproval).toHaveBeenCalledWith(
317
- tx,
318
- 55,
319
- 7,
320
- );
321
- });
322
-
323
- it("uses the task assignment when full timesheet entries omit projectAssignmentId", async () => {
324
- const tx = {
325
- $queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 55 }]),
326
- $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
327
- };
328
-
329
- (service as any).prisma.$transaction.mockImplementation(
330
- async (callback: (client: unknown) => unknown) => callback(tx),
331
- );
332
-
333
- jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
334
- id: 7,
335
- supervisorId: 18,
336
- });
337
- jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
338
- id: 44,
339
- name: "Implement API",
340
- projectAssignmentId: 33,
341
- projectId: 11,
342
- projectName: "Project Atlas",
343
- projectCode: "OPS-11",
344
- });
345
- jest
346
- .spyOn(service as any, "refreshTimesheetTotal")
347
- .mockResolvedValue(undefined);
348
- jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
349
- id: 55,
350
- status: "draft",
351
- });
352
-
353
- await service.createTimesheet(15, {
354
- weekStartDate: "2026-04-06",
355
- weekEndDate: "2026-04-12",
356
- entries: [
357
- {
358
- taskId: 44,
359
- workDate: "2026-04-09",
360
- duration: 2,
361
- unit: "hours",
362
- description: "Worked on the API layer",
363
- },
364
- ],
365
- } as any);
366
-
367
- expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
368
- expect.stringContaining("INSERT INTO operations_timesheet_entry"),
369
- 55,
370
- 33,
371
- 44,
372
- "Implement API",
373
- "2026-04-09",
374
- 120,
375
- 2,
376
- "Worked on the API layer",
377
- );
378
- });
379
-
380
- it("rejects submitting a timesheet without a resolved approver", async () => {
381
- const tx = {
382
- $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
383
- $queryRawUnsafe: jest
384
- .fn()
385
- .mockResolvedValueOnce([])
386
- .mockResolvedValueOnce([{ exists: true }]),
387
- };
388
-
389
- (service as any).prisma.$transaction.mockImplementation(
390
- async (callback: (client: unknown) => unknown) => callback(tx),
391
- );
392
-
393
- jest.spyOn(service as any, "getTimesheetById").mockResolvedValue({
394
- id: 55,
395
- collaboratorId: 7,
396
- approverCollaboratorId: null,
397
- status: "draft",
398
- });
399
- jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
400
- id: 7,
401
- supervisorId: null,
402
- });
403
- jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
404
- id: 55,
405
- status: "submitted",
406
- });
407
-
408
- await expect(service.submitTimesheet(15, 55)).rejects.toThrow(
409
- BadRequestException,
410
- );
411
- expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
412
- });
413
-
414
- it("rejects submitting a timesheet without active entries", async () => {
415
- const tx = {
416
- $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
417
- $queryRawUnsafe: jest
418
- .fn()
419
- .mockResolvedValueOnce([{ managerCollaboratorId: 18 }])
420
- .mockResolvedValueOnce([{ exists: false }]),
421
- };
422
-
423
- (service as any).prisma.$transaction.mockImplementation(
424
- async (callback: (client: unknown) => unknown) => callback(tx),
425
- );
426
-
427
- jest.spyOn(service as any, "getTimesheetById").mockResolvedValue({
428
- id: 56,
429
- collaboratorId: 7,
430
- approverCollaboratorId: 18,
431
- status: "draft",
432
- });
433
- jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
434
- id: 7,
435
- supervisorId: 18,
436
- });
437
- jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
438
- id: 56,
439
- status: "submitted",
440
- });
441
-
442
- await expect(service.submitTimesheet(15, 56)).rejects.toThrow(
443
- BadRequestException,
444
- );
445
- expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
446
- });
447
-
448
- it("deletes only draft quick entries and refreshes the weekly total", async () => {
449
- const tx = {
450
- $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
451
- $queryRawUnsafe: jest.fn().mockResolvedValue([]),
452
- };
453
-
454
- (service as any).prisma.$transaction.mockImplementation(
455
- async (callback: (client: unknown) => unknown) => callback(tx),
456
- );
457
-
458
- jest
459
- .spyOn(service as any, "getTimesheetEntryByIdForActor")
460
- .mockResolvedValue({
461
- id: 91,
462
- timesheetId: 55,
463
- collaboratorId: 7,
464
- status: "draft",
465
- });
466
- jest
467
- .spyOn(service as any, "refreshTimesheetTotal")
468
- .mockResolvedValue(undefined);
469
-
470
- await expect(service.removeTimesheetEntry(15, 91)).resolves.toEqual({
471
- success: true,
472
- });
473
-
474
- expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
475
- expect.stringContaining("UPDATE operations_timesheet_entry"),
476
- 91,
477
- );
478
- expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 55);
479
- });
480
-
481
- it("updates quick entries and moves them to the correct weekly timesheet", async () => {
482
- const tx = {
483
- $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
484
- $queryRawUnsafe: jest.fn().mockResolvedValue([]),
485
- };
486
-
487
- (service as any).prisma.$transaction.mockImplementation(
488
- async (callback: (client: unknown) => unknown) => callback(tx),
489
- );
490
-
491
- jest
492
- .spyOn(service as any, "getTimesheetEntryByIdForActor")
493
- .mockResolvedValueOnce({
494
- id: 93,
495
- timesheetId: 55,
496
- collaboratorId: 7,
497
- projectId: 11,
498
- projectAssignmentId: 33,
499
- taskId: 44,
500
- status: "draft",
501
- weekStartDate: "2026-04-06",
502
- weekEndDate: "2026-04-12",
503
- workDate: "2026-04-09",
504
- hours: 1,
505
- durationMinutes: 60,
506
- })
507
- .mockResolvedValueOnce({
508
- id: 93,
509
- timesheetId: 77,
510
- collaboratorId: 7,
511
- projectId: 12,
512
- projectAssignmentId: 34,
513
- taskId: 45,
514
- status: "draft",
515
- weekStartDate: "2026-04-13",
516
- weekEndDate: "2026-04-19",
517
- workDate: "2026-04-14",
518
- hours: 2,
519
- durationMinutes: 120,
520
- taskName: "Review backlog",
521
- });
522
- jest
523
- .spyOn(service as any, "resolveOwnedProjectAssignment")
524
- .mockResolvedValue({
525
- id: 34,
526
- projectId: 12,
527
- projectName: "Project Boreal",
528
- projectCode: "OPS-12",
529
- roleLabel: "Engineer",
530
- });
531
- jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
532
- id: 45,
533
- name: "Review backlog",
534
- projectAssignmentId: 34,
535
- projectId: 12,
536
- projectName: "Project Boreal",
537
- projectCode: "OPS-12",
538
- });
539
- jest
540
- .spyOn(service as any, "getOrCreateTimesheetForWorkDate")
541
- .mockResolvedValue(77);
542
- jest
543
- .spyOn(service as any, "refreshTimesheetTotal")
544
- .mockResolvedValue(undefined);
545
- jest
546
- .spyOn(service as any, "cleanupEmptyEditableTimesheet")
547
- .mockResolvedValue(undefined);
548
- jest
549
- .spyOn(service as any, "submitTimesheetForApproval")
550
- .mockResolvedValue(undefined);
551
-
552
- const result = await service.updateTimesheetEntry(15, 93, {
553
- projectId: 12,
554
- projectAssignmentId: 34,
555
- taskId: 45,
556
- workDate: "2026-04-14",
557
- duration: 2,
558
- unit: "hours",
559
- description: "Backlog refinement",
560
- } as any);
561
-
562
- expect(result).toEqual(
563
- expect.objectContaining({
564
- id: 93,
565
- timesheetId: 77,
566
- }),
567
- );
568
- expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
569
- expect.stringContaining("UPDATE operations_timesheet_entry"),
570
- 77,
571
- 34,
572
- 45,
573
- "Review backlog",
574
- "2026-04-14",
575
- 120,
576
- 2,
577
- "Backlog refinement",
578
- 93,
579
- );
580
- expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 55);
581
- expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 77);
582
- expect((service as any).cleanupEmptyEditableTimesheet).toHaveBeenCalledWith(
583
- tx,
584
- 55,
585
- );
586
- expect((service as any).submitTimesheetForApproval).toHaveBeenCalledWith(
587
- tx,
588
- 77,
589
- 7,
590
- );
591
- });
592
-
593
- it("allows deleting entries from submitted timesheets until approval", async () => {
594
- const tx = {
595
- $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
596
- };
597
-
598
- (service as any).prisma.$transaction.mockImplementation(
599
- async (callback: (client: unknown) => unknown) => callback(tx),
600
- );
601
-
602
- jest
603
- .spyOn(service as any, "getTimesheetEntryByIdForActor")
604
- .mockResolvedValue({
605
- id: 92,
606
- timesheetId: 56,
607
- collaboratorId: 7,
608
- status: "submitted",
609
- });
610
- jest
611
- .spyOn(service as any, "refreshTimesheetTotal")
612
- .mockResolvedValue(undefined);
613
- jest
614
- .spyOn(service as any, "cleanupEmptyEditableTimesheet")
615
- .mockResolvedValue(undefined);
616
-
617
- await expect(service.removeTimesheetEntry(15, 92)).resolves.toEqual({
618
- success: true,
619
- });
620
- });
621
-
622
- it("rejects deleting entries from approved timesheets", async () => {
623
- jest
624
- .spyOn(service as any, "getTimesheetEntryByIdForActor")
625
- .mockResolvedValue({
626
- id: 94,
627
- timesheetId: 57,
628
- collaboratorId: 7,
629
- status: "approved",
630
- });
631
-
632
- await expect(service.removeTimesheetEntry(15, 94)).rejects.toThrow(
633
- BadRequestException,
634
- );
635
- });
636
- });
637
-
638
- describe("OperationsService task column activity", () => {
639
- let service: OperationsService;
640
- let prisma: { $transaction: jest.Mock };
641
- let integrationApi: { publishEvent: jest.Mock };
642
-
643
- const baseTask = {
644
- id: 44,
645
- name: "Implement board",
646
- description: null,
647
- priority: "medium",
648
- status: "todo",
649
- dueDate: null,
650
- estimateHours: null,
651
- position: 0,
652
- tags: null,
653
- assigneeCollaboratorId: null,
654
- projectAssignmentId: null,
655
- projectId: 11,
656
- projectName: "Project Atlas",
657
- projectCode: "OPS-11",
658
- doingStartedAt: null,
659
- totalDoingMinutes: 0,
660
- deletedAt: null,
661
- };
662
-
663
- beforeEach(() => {
664
- prisma = {
665
- $transaction: jest.fn(),
666
- };
667
- integrationApi = {
668
- publishEvent: jest.fn().mockResolvedValue(undefined),
669
- };
670
- service = new OperationsService(
671
- prisma as any,
672
- {} as any,
673
- integrationApi as any,
674
- {} as any,
675
- {} as any,
676
- new OperationsAccessService(),
677
- );
678
-
679
- jest.spyOn(service as any, "getActorContext").mockResolvedValue({
680
- userId: 15,
681
- roleSlugs: ["operations-collaborator"],
682
- collaboratorId: 7,
683
- collaboratorName: "Taylor Tester",
684
- isDirector: false,
685
- isSupervisor: false,
686
- isCollaborator: true,
687
- teamCollaboratorIds: [],
688
- visibleCollaboratorIds: [7],
689
- visibleProjectIds: [11],
690
- });
691
- jest
692
- .spyOn(service as any, "assertProjectAccess")
693
- .mockResolvedValue(undefined);
694
- jest.spyOn(service as any, "getProjectBoardTask").mockResolvedValue({
695
- ...baseTask,
696
- assigneeCollaboratorId: 7,
697
- status: "doing",
698
- });
699
- });
700
-
701
- afterEach(() => {
702
- jest.restoreAllMocks();
703
- });
704
-
705
- function mockTransaction() {
706
- const tx = {
707
- $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
708
- };
709
- prisma.$transaction.mockImplementation(
710
- async (callback: (client: unknown) => unknown) => callback(tx),
711
- );
712
- return tx;
713
- }
714
-
715
- it("records todo to doing and auto-assigns the actor", async () => {
716
- const tx = mockTransaction();
717
- jest
718
- .spyOn(service as any, "getTaskRecordForActor")
719
- .mockResolvedValue(baseTask);
720
-
721
- await service.updateTask(15, 44, { status: "doing" } as any);
722
-
723
- expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(2);
724
- expect(tx.$executeRawUnsafe.mock.calls[0][0]).toContain(
725
- "doing_started_at = CASE",
726
- );
727
- expect(tx.$executeRawUnsafe.mock.calls[0][3]).toBe(7);
728
- expect(tx.$executeRawUnsafe.mock.calls[0][15]).toBe(true);
729
- expect(tx.$executeRawUnsafe.mock.calls[0][16]).toBe(false);
730
- expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
731
- "INSERT INTO operations_task_activity",
732
- );
733
- expect(tx.$executeRawUnsafe.mock.calls[1].slice(1)).toEqual([
734
- 44,
735
- 7,
736
- "status_changed",
737
- "todo",
738
- "doing",
739
- ]);
740
- });
741
-
742
- it("records doing to review and closes accumulated doing minutes", async () => {
743
- const tx = mockTransaction();
744
- jest.spyOn(service as any, "getTaskRecordForActor").mockResolvedValue({
745
- ...baseTask,
746
- status: "doing",
747
- assigneeCollaboratorId: 9,
748
- doingStartedAt: "2026-05-16T10:00:00.000Z",
749
- totalDoingMinutes: 15,
750
- });
751
-
752
- await service.updateTask(15, 44, { status: "review" } as any);
753
-
754
- expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(2);
755
- expect(tx.$executeRawUnsafe.mock.calls[0][3]).toBe(9);
756
- expect(tx.$executeRawUnsafe.mock.calls[0][15]).toBe(false);
757
- expect(tx.$executeRawUnsafe.mock.calls[0][16]).toBe(true);
758
- expect(tx.$executeRawUnsafe.mock.calls[1].slice(1)).toEqual([
759
- 44,
760
- 7,
761
- "status_changed",
762
- "doing",
763
- "review",
764
- ]);
765
- });
766
-
767
- it("blocks moving advanced without a linked collaborator when no assignee exists", async () => {
768
- prisma.$transaction.mockImplementation(
769
- async (callback: (client: unknown) => unknown) =>
770
- callback({ $executeRawUnsafe: jest.fn() }),
771
- );
772
- jest.spyOn(service as any, "getActorContext").mockResolvedValue({
773
- userId: 15,
774
- roleSlugs: ["admin"],
775
- collaboratorId: null,
776
- collaboratorName: null,
777
- isDirector: true,
778
- isSupervisor: true,
779
- isCollaborator: true,
780
- teamCollaboratorIds: [],
781
- visibleCollaboratorIds: [],
782
- visibleProjectIds: [],
783
- });
784
- jest
785
- .spyOn(service as any, "getTaskRecordForActor")
786
- .mockResolvedValue(baseTask);
787
-
788
- await expect(
789
- service.updateTask(15, 44, { status: "review" } as any),
790
- ).rejects.toThrow(BadRequestException);
791
- });
792
-
793
- it("does not create activity when the status does not change", async () => {
794
- const tx = mockTransaction();
795
- jest
796
- .spyOn(service as any, "getTaskRecordForActor")
797
- .mockResolvedValue(baseTask);
798
-
799
- await service.updateTask(15, 44, { status: "todo" } as any);
800
-
801
- expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(1);
802
- expect(tx.$executeRawUnsafe.mock.calls[0][15]).toBe(false);
803
- expect(tx.$executeRawUnsafe.mock.calls[0][16]).toBe(false);
804
- });
805
- });
806
-
807
- describe("OperationsService approval side effects", () => {
808
- let service: OperationsService;
809
-
810
- beforeEach(() => {
811
- service = new OperationsService(
812
- {
813
- $transaction: jest.fn(),
814
- } as any,
815
- {} as any,
816
- {
817
- publishEvent: jest.fn().mockResolvedValue(undefined),
818
- } as any,
819
- {} as any,
820
- {} as any,
821
- new OperationsAccessService(),
822
- );
823
-
824
- jest.spyOn(service as any, "getActorContext").mockResolvedValue({
825
- userId: 22,
826
- roleSlugs: ["admin-operations-supervisor"],
827
- collaboratorId: 18,
828
- collaboratorName: "Sam Supervisor",
829
- isDirector: false,
830
- isSupervisor: true,
831
- isCollaborator: false,
832
- teamCollaboratorIds: [7],
833
- visibleCollaboratorIds: [7, 18],
834
- visibleProjectIds: [11],
835
- });
836
- });
837
-
838
- afterEach(() => {
839
- jest.restoreAllMocks();
840
- });
841
-
842
- it("applies approved permanent schedule adjustments to the active collaborator weekly schedule", async () => {
843
- const tx = {
844
- $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
845
- $queryRawUnsafe: jest
846
- .fn()
847
- .mockResolvedValueOnce([
848
- {
849
- collaboratorId: 7,
850
- requestScope: "permanent",
851
- },
852
- ])
853
- .mockResolvedValueOnce([
854
- {
855
- weekday: "monday",
856
- isWorkingDay: true,
857
- startTime: "08:00",
858
- endTime: "17:00",
859
- breakMinutes: 45,
860
- },
861
- {
862
- weekday: "friday",
863
- isWorkingDay: false,
864
- startTime: null,
865
- endTime: null,
866
- breakMinutes: 0,
867
- },
868
- ]),
869
- };
870
-
871
- (service as any).prisma.$transaction.mockImplementation(
872
- async (callback: (client: unknown) => unknown) => callback(tx),
873
- );
874
-
875
- jest
876
- .spyOn(service as any, "querySingle")
877
- .mockResolvedValueOnce({
878
- id: 81,
879
- targetType: "schedule_adjustment_request",
880
- targetId: 400,
881
- requesterCollaboratorId: 7,
882
- approverCollaboratorId: 18,
883
- status: "pending",
884
- })
885
- .mockResolvedValueOnce({
886
- id: 81,
887
- targetType: "schedule_adjustment_request",
888
- targetId: 400,
889
- status: "approved",
890
- decidedAt: "2026-04-11T12:00:00.000Z",
891
- decisionNote: "Approved for the new weekly schedule.",
892
- });
893
- jest
894
- .spyOn(service as any, "insertApprovalHistory")
895
- .mockResolvedValue(undefined);
896
-
897
- await expect(
898
- service.approve(22, 81, {
899
- note: "Approved for the new weekly schedule.",
900
- }),
901
- ).resolves.toEqual(
902
- expect.objectContaining({
903
- id: 81,
904
- status: "approved",
905
- }),
906
- );
907
-
908
- expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
909
- expect.stringContaining("UPDATE operations_schedule_adjustment_request"),
910
- "approved",
911
- 18,
912
- 400,
913
- );
914
- expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
915
- expect.stringContaining("UPDATE operations_collaborator_schedule_day"),
916
- 7,
917
- );
918
- expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
919
- expect.stringContaining(
920
- "INSERT INTO operations_collaborator_schedule_day",
921
- ),
922
- 7,
923
- "monday",
924
- true,
925
- "08:00",
926
- "17:00",
927
- 45,
928
- );
929
- expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
930
- expect.stringContaining(
931
- "INSERT INTO operations_collaborator_schedule_day",
932
- ),
933
- 7,
934
- "friday",
935
- false,
936
- null,
937
- null,
938
- 0,
939
- );
940
- });
941
-
942
- it("keeps the collaborator weekly schedule unchanged for approved temporary adjustments", async () => {
943
- const tx = {
944
- $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
945
- $queryRawUnsafe: jest.fn().mockResolvedValueOnce([
946
- {
947
- collaboratorId: 7,
948
- requestScope: "temporary",
949
- },
950
- ]),
951
- };
952
-
953
- (service as any).prisma.$transaction.mockImplementation(
954
- async (callback: (client: unknown) => unknown) => callback(tx),
955
- );
956
-
957
- jest
958
- .spyOn(service as any, "querySingle")
959
- .mockResolvedValueOnce({
960
- id: 82,
961
- targetType: "schedule_adjustment_request",
962
- targetId: 401,
963
- requesterCollaboratorId: 7,
964
- approverCollaboratorId: 18,
965
- status: "pending",
966
- })
967
- .mockResolvedValueOnce({
968
- id: 82,
969
- targetType: "schedule_adjustment_request",
970
- targetId: 401,
971
- status: "approved",
972
- decidedAt: "2026-04-11T12:05:00.000Z",
973
- decisionNote: "Approved as a temporary exception.",
974
- });
975
- jest
976
- .spyOn(service as any, "insertApprovalHistory")
977
- .mockResolvedValue(undefined);
978
-
979
- await service.approve(22, 82, {
980
- note: "Approved as a temporary exception.",
981
- });
982
-
983
- expect(tx.$executeRawUnsafe).not.toHaveBeenCalledWith(
984
- expect.stringContaining("UPDATE operations_collaborator_schedule_day"),
985
- 7,
986
- );
987
- });
988
- });
1
+ /// <reference types="jest" />
2
+
3
+ import { BadRequestException } from "@nestjs/common";
4
+ import { OperationsService } from "./operations.service";
5
+ import { OperationsAccessService } from "./services/shared/operations-access.service";
6
+
7
+ describe("OperationsService proposal integration", () => {
8
+ let service: OperationsService;
9
+ let prisma: { $transaction: jest.Mock };
10
+ let integrationApi: { publishEvent: jest.Mock };
11
+
12
+ beforeEach(() => {
13
+ prisma = {
14
+ $transaction: jest.fn(),
15
+ };
16
+
17
+ integrationApi = {
18
+ publishEvent: jest.fn().mockResolvedValue(undefined),
19
+ };
20
+
21
+ service = new OperationsService(
22
+ prisma as any,
23
+ {} as any,
24
+ integrationApi as any,
25
+ {} as any,
26
+ {} as any,
27
+ new OperationsAccessService(),
28
+ );
29
+
30
+ jest
31
+ .spyOn(service as any, "generateContractCode")
32
+ .mockResolvedValue("CTR-001");
33
+ jest
34
+ .spyOn(service as any, "replaceContractParties")
35
+ .mockResolvedValue(undefined);
36
+ jest
37
+ .spyOn(service as any, "insertContractHistory")
38
+ .mockResolvedValue(undefined);
39
+ });
40
+
41
+ afterEach(() => {
42
+ jest.restoreAllMocks();
43
+ });
44
+
45
+ it("throws when proposalId is missing", async () => {
46
+ await expect(
47
+ service.createContractFromProposalIntegration({}),
48
+ ).rejects.toThrow(BadRequestException);
49
+ });
50
+
51
+ it("returns the existing contract when the CRM proposal was already converted", async () => {
52
+ const tx = {
53
+ $queryRawUnsafe: jest
54
+ .fn()
55
+ .mockResolvedValueOnce([])
56
+ .mockResolvedValueOnce([{ id: 42, code: "CTR-EXISTING" }]),
57
+ };
58
+
59
+ prisma.$transaction.mockImplementation(
60
+ async (callback: (client: unknown) => unknown) => callback(tx),
61
+ );
62
+
63
+ const result = await service.createContractFromProposalIntegration({
64
+ proposalId: 1001,
65
+ title: "Already converted proposal",
66
+ });
67
+
68
+ expect(result).toEqual({
69
+ id: 42,
70
+ code: "CTR-EXISTING",
71
+ });
72
+ expect((service as any).generateContractCode).not.toHaveBeenCalled();
73
+ expect((service as any).replaceContractParties).not.toHaveBeenCalled();
74
+ expect(integrationApi.publishEvent).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it("creates a draft contract with CRM origin metadata and emits an event", async () => {
78
+ const tx = {
79
+ $queryRawUnsafe: jest
80
+ .fn()
81
+ .mockResolvedValueOnce([])
82
+ .mockResolvedValueOnce([])
83
+ .mockResolvedValueOnce([{ id: 77 }]),
84
+ };
85
+
86
+ prisma.$transaction.mockImplementation(
87
+ async (callback: (client: unknown) => unknown) => callback(tx),
88
+ );
89
+
90
+ const payload = {
91
+ proposalId: 1001,
92
+ proposalRevisionId: 21,
93
+ personId: 5,
94
+ approvedByUserId: 9,
95
+ correlationId: "proposal:1001",
96
+ code: "P-1001",
97
+ title: "Implementation Proposal",
98
+ total: 1234,
99
+ currency: "USD",
100
+ locale: "en",
101
+ commercialTerms: {
102
+ contractCategory: "client" as const,
103
+ contractType: "service_agreement" as const,
104
+ billingModel: "monthly_retainer" as const,
105
+ validFrom: "2025-01-10",
106
+ validUntil: "2025-12-31",
107
+ notes: "Annual support agreement",
108
+ },
109
+ person: {
110
+ id: 5,
111
+ name: "Acme Contact",
112
+ tradeName: "Acme Ltd",
113
+ email: "ops@acme.test",
114
+ phone: "555-0100",
115
+ document: "12.345.678/0001-90",
116
+ },
117
+ revision: {
118
+ id: 21,
119
+ title: "Revision 1",
120
+ summary: "Approved commercial terms",
121
+ contentHtml: "<p>draft</p>",
122
+ },
123
+ items: [
124
+ {
125
+ name: "Monthly retainer",
126
+ description: "Support hours",
127
+ amount: 1000,
128
+ recurrence: "monthly" as const,
129
+ },
130
+ {
131
+ name: "Setup fee",
132
+ amount: 234,
133
+ recurrence: "one_time" as const,
134
+ },
135
+ ],
136
+ };
137
+
138
+ const result = await service.createContractFromProposalIntegration(payload);
139
+
140
+ expect(result).toEqual({
141
+ id: 77,
142
+ code: "CTR-001",
143
+ });
144
+
145
+ const insertCall = tx.$queryRawUnsafe.mock.calls[2];
146
+ expect(insertCall[0]).toContain("INSERT INTO operations_contract");
147
+ expect(insertCall).toContain("crm_proposal");
148
+ expect(insertCall).toContain("1001");
149
+
150
+ expect((service as any).replaceContractParties).toHaveBeenCalledWith(
151
+ tx,
152
+ 77,
153
+ [
154
+ expect.objectContaining({
155
+ partyRole: "client",
156
+ displayName: "Acme Ltd",
157
+ isPrimary: true,
158
+ }),
159
+ ],
160
+ );
161
+ expect((service as any).insertContractHistory).toHaveBeenCalledWith(
162
+ tx,
163
+ 77,
164
+ 9,
165
+ "created",
166
+ "Draft contract generated from CRM proposal P-1001.",
167
+ expect.any(String),
168
+ );
169
+
170
+ expect(integrationApi.publishEvent).toHaveBeenCalledWith(
171
+ expect.objectContaining({
172
+ eventName: "operations.contract.created",
173
+ payload: expect.objectContaining({
174
+ proposalId: 1001,
175
+ contract: expect.objectContaining({
176
+ id: 77,
177
+ originType: "crm_proposal",
178
+ originId: "1001",
179
+ contractType: "service_agreement",
180
+ }),
181
+ }),
182
+ }),
183
+ expect.objectContaining({
184
+ persistenceClient: tx,
185
+ }),
186
+ );
187
+ });
188
+ });
189
+
190
+ describe("OperationsService quick-entry timesheets", () => {
191
+ let service: OperationsService;
192
+
193
+ beforeEach(() => {
194
+ service = new OperationsService(
195
+ {
196
+ $transaction: jest.fn(),
197
+ } as any,
198
+ {} as any,
199
+ {
200
+ publishEvent: jest.fn().mockResolvedValue(undefined),
201
+ } as any,
202
+ {} as any,
203
+ {} as any,
204
+ new OperationsAccessService(),
205
+ );
206
+
207
+ jest.spyOn(service as any, "getActorContext").mockResolvedValue({
208
+ userId: 15,
209
+ roleSlugs: ["operations-collaborator"],
210
+ collaboratorId: 7,
211
+ collaboratorName: "Taylor Tester",
212
+ isDirector: false,
213
+ isSupervisor: false,
214
+ isCollaborator: true,
215
+ teamCollaboratorIds: [],
216
+ visibleCollaboratorIds: [7],
217
+ visibleProjectIds: [11],
218
+ });
219
+ });
220
+
221
+ afterEach(() => {
222
+ jest.restoreAllMocks();
223
+ });
224
+
225
+ it("rejects quick entries with non-positive duration", async () => {
226
+ await expect(
227
+ service.createTimesheetEntry(15, {
228
+ projectId: 11,
229
+ workDate: "2026-04-09",
230
+ duration: 0,
231
+ unit: "minutes",
232
+ } as any),
233
+ ).rejects.toThrow(BadRequestException);
234
+ });
235
+
236
+ it("rejects quick entries without a work date", async () => {
237
+ await expect(
238
+ service.createTimesheetEntry(15, {
239
+ projectId: 11,
240
+ duration: 30,
241
+ unit: "minutes",
242
+ } as any),
243
+ ).rejects.toThrow(BadRequestException);
244
+ });
245
+
246
+ it("stores quick-entry duration in minutes and keeps hours compatible", async () => {
247
+ const tx = {
248
+ $queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 91 }]),
249
+ };
250
+
251
+ (service as any).prisma.$transaction.mockImplementation(
252
+ async (callback: (client: unknown) => unknown) => callback(tx),
253
+ );
254
+
255
+ jest
256
+ .spyOn(service as any, "resolveOwnedProjectAssignment")
257
+ .mockResolvedValue({
258
+ id: 33,
259
+ projectId: 11,
260
+ projectName: "Project Atlas",
261
+ projectCode: "OPS-11",
262
+ roleLabel: "Engineer",
263
+ });
264
+ jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
265
+ id: 44,
266
+ name: "Implement API",
267
+ projectAssignmentId: 33,
268
+ projectId: 11,
269
+ projectName: "Project Atlas",
270
+ projectCode: "OPS-11",
271
+ });
272
+ jest
273
+ .spyOn(service as any, "getOrCreateTimesheetForWorkDate")
274
+ .mockResolvedValue(55);
275
+ jest
276
+ .spyOn(service as any, "refreshTimesheetTotal")
277
+ .mockResolvedValue(undefined);
278
+ jest
279
+ .spyOn(service as any, "submitTimesheetForApproval")
280
+ .mockResolvedValue(undefined);
281
+ jest
282
+ .spyOn(service as any, "getTimesheetEntryByIdForActor")
283
+ .mockResolvedValue({
284
+ id: 91,
285
+ durationMinutes: 90,
286
+ hours: 1.5,
287
+ });
288
+
289
+ const result = await service.createTimesheetEntry(15, {
290
+ projectId: 11,
291
+ taskId: 44,
292
+ workDate: "2026-04-09",
293
+ duration: 1.5,
294
+ unit: "hours",
295
+ description: "Worked on the API layer",
296
+ } as any);
297
+
298
+ expect(result).toEqual(
299
+ expect.objectContaining({
300
+ id: 91,
301
+ durationMinutes: 90,
302
+ hours: 1.5,
303
+ }),
304
+ );
305
+ expect(tx.$queryRawUnsafe).toHaveBeenCalledWith(
306
+ expect.stringContaining("INSERT INTO operations_timesheet_entry"),
307
+ 55,
308
+ 33,
309
+ 44,
310
+ "Implement API",
311
+ "2026-04-09",
312
+ 90,
313
+ 1.5,
314
+ "Worked on the API layer",
315
+ );
316
+ expect((service as any).submitTimesheetForApproval).toHaveBeenCalledWith(
317
+ tx,
318
+ 55,
319
+ 7,
320
+ );
321
+ });
322
+
323
+ it("uses the task assignment when full timesheet entries omit projectAssignmentId", async () => {
324
+ const tx = {
325
+ $queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 55 }]),
326
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
327
+ };
328
+
329
+ (service as any).prisma.$transaction.mockImplementation(
330
+ async (callback: (client: unknown) => unknown) => callback(tx),
331
+ );
332
+
333
+ jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
334
+ id: 7,
335
+ supervisorId: 18,
336
+ });
337
+ jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
338
+ id: 44,
339
+ name: "Implement API",
340
+ projectAssignmentId: 33,
341
+ projectId: 11,
342
+ projectName: "Project Atlas",
343
+ projectCode: "OPS-11",
344
+ });
345
+ jest
346
+ .spyOn(service as any, "refreshTimesheetTotal")
347
+ .mockResolvedValue(undefined);
348
+ jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
349
+ id: 55,
350
+ status: "draft",
351
+ });
352
+
353
+ await service.createTimesheet(15, {
354
+ weekStartDate: "2026-04-06",
355
+ weekEndDate: "2026-04-12",
356
+ entries: [
357
+ {
358
+ taskId: 44,
359
+ workDate: "2026-04-09",
360
+ duration: 2,
361
+ unit: "hours",
362
+ description: "Worked on the API layer",
363
+ },
364
+ ],
365
+ } as any);
366
+
367
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
368
+ expect.stringContaining("INSERT INTO operations_timesheet_entry"),
369
+ 55,
370
+ 33,
371
+ 44,
372
+ "Implement API",
373
+ "2026-04-09",
374
+ 120,
375
+ 2,
376
+ "Worked on the API layer",
377
+ );
378
+ });
379
+
380
+ it("rejects submitting a timesheet without a resolved approver", async () => {
381
+ const tx = {
382
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
383
+ $queryRawUnsafe: jest
384
+ .fn()
385
+ .mockResolvedValueOnce([])
386
+ .mockResolvedValueOnce([{ exists: true }]),
387
+ };
388
+
389
+ (service as any).prisma.$transaction.mockImplementation(
390
+ async (callback: (client: unknown) => unknown) => callback(tx),
391
+ );
392
+
393
+ jest.spyOn(service as any, "getTimesheetById").mockResolvedValue({
394
+ id: 55,
395
+ collaboratorId: 7,
396
+ approverCollaboratorId: null,
397
+ status: "draft",
398
+ });
399
+ jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
400
+ id: 7,
401
+ supervisorId: null,
402
+ });
403
+ jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
404
+ id: 55,
405
+ status: "submitted",
406
+ });
407
+
408
+ await expect(service.submitTimesheet(15, 55)).rejects.toThrow(
409
+ BadRequestException,
410
+ );
411
+ expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
412
+ });
413
+
414
+ it("rejects submitting a timesheet without active entries", async () => {
415
+ const tx = {
416
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
417
+ $queryRawUnsafe: jest
418
+ .fn()
419
+ .mockResolvedValueOnce([{ managerCollaboratorId: 18 }])
420
+ .mockResolvedValueOnce([{ exists: false }]),
421
+ };
422
+
423
+ (service as any).prisma.$transaction.mockImplementation(
424
+ async (callback: (client: unknown) => unknown) => callback(tx),
425
+ );
426
+
427
+ jest.spyOn(service as any, "getTimesheetById").mockResolvedValue({
428
+ id: 56,
429
+ collaboratorId: 7,
430
+ approverCollaboratorId: 18,
431
+ status: "draft",
432
+ });
433
+ jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
434
+ id: 7,
435
+ supervisorId: 18,
436
+ });
437
+ jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
438
+ id: 56,
439
+ status: "submitted",
440
+ });
441
+
442
+ await expect(service.submitTimesheet(15, 56)).rejects.toThrow(
443
+ BadRequestException,
444
+ );
445
+ expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
446
+ });
447
+
448
+ it("deletes only draft quick entries and refreshes the weekly total", async () => {
449
+ const tx = {
450
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
451
+ $queryRawUnsafe: jest.fn().mockResolvedValue([]),
452
+ };
453
+
454
+ (service as any).prisma.$transaction.mockImplementation(
455
+ async (callback: (client: unknown) => unknown) => callback(tx),
456
+ );
457
+
458
+ jest
459
+ .spyOn(service as any, "getTimesheetEntryByIdForActor")
460
+ .mockResolvedValue({
461
+ id: 91,
462
+ timesheetId: 55,
463
+ collaboratorId: 7,
464
+ status: "draft",
465
+ });
466
+ jest
467
+ .spyOn(service as any, "refreshTimesheetTotal")
468
+ .mockResolvedValue(undefined);
469
+
470
+ await expect(service.removeTimesheetEntry(15, 91)).resolves.toEqual({
471
+ success: true,
472
+ });
473
+
474
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
475
+ expect.stringContaining("UPDATE operations_timesheet_entry"),
476
+ 91,
477
+ );
478
+ expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 55);
479
+ });
480
+
481
+ it("updates quick entries and moves them to the correct weekly timesheet", async () => {
482
+ const tx = {
483
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
484
+ $queryRawUnsafe: jest.fn().mockResolvedValue([]),
485
+ };
486
+
487
+ (service as any).prisma.$transaction.mockImplementation(
488
+ async (callback: (client: unknown) => unknown) => callback(tx),
489
+ );
490
+
491
+ jest
492
+ .spyOn(service as any, "getTimesheetEntryByIdForActor")
493
+ .mockResolvedValueOnce({
494
+ id: 93,
495
+ timesheetId: 55,
496
+ collaboratorId: 7,
497
+ projectId: 11,
498
+ projectAssignmentId: 33,
499
+ taskId: 44,
500
+ status: "draft",
501
+ weekStartDate: "2026-04-06",
502
+ weekEndDate: "2026-04-12",
503
+ workDate: "2026-04-09",
504
+ hours: 1,
505
+ durationMinutes: 60,
506
+ })
507
+ .mockResolvedValueOnce({
508
+ id: 93,
509
+ timesheetId: 77,
510
+ collaboratorId: 7,
511
+ projectId: 12,
512
+ projectAssignmentId: 34,
513
+ taskId: 45,
514
+ status: "draft",
515
+ weekStartDate: "2026-04-13",
516
+ weekEndDate: "2026-04-19",
517
+ workDate: "2026-04-14",
518
+ hours: 2,
519
+ durationMinutes: 120,
520
+ taskName: "Review backlog",
521
+ });
522
+ jest
523
+ .spyOn(service as any, "resolveOwnedProjectAssignment")
524
+ .mockResolvedValue({
525
+ id: 34,
526
+ projectId: 12,
527
+ projectName: "Project Boreal",
528
+ projectCode: "OPS-12",
529
+ roleLabel: "Engineer",
530
+ });
531
+ jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
532
+ id: 45,
533
+ name: "Review backlog",
534
+ projectAssignmentId: 34,
535
+ projectId: 12,
536
+ projectName: "Project Boreal",
537
+ projectCode: "OPS-12",
538
+ });
539
+ jest
540
+ .spyOn(service as any, "getOrCreateTimesheetForWorkDate")
541
+ .mockResolvedValue(77);
542
+ jest
543
+ .spyOn(service as any, "refreshTimesheetTotal")
544
+ .mockResolvedValue(undefined);
545
+ jest
546
+ .spyOn(service as any, "cleanupEmptyEditableTimesheet")
547
+ .mockResolvedValue(undefined);
548
+ jest
549
+ .spyOn(service as any, "submitTimesheetForApproval")
550
+ .mockResolvedValue(undefined);
551
+
552
+ const result = await service.updateTimesheetEntry(15, 93, {
553
+ projectId: 12,
554
+ projectAssignmentId: 34,
555
+ taskId: 45,
556
+ workDate: "2026-04-14",
557
+ duration: 2,
558
+ unit: "hours",
559
+ description: "Backlog refinement",
560
+ } as any);
561
+
562
+ expect(result).toEqual(
563
+ expect.objectContaining({
564
+ id: 93,
565
+ timesheetId: 77,
566
+ }),
567
+ );
568
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
569
+ expect.stringContaining("UPDATE operations_timesheet_entry"),
570
+ 77,
571
+ 34,
572
+ 45,
573
+ "Review backlog",
574
+ "2026-04-14",
575
+ 120,
576
+ 2,
577
+ "Backlog refinement",
578
+ 93,
579
+ );
580
+ expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 55);
581
+ expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 77);
582
+ expect((service as any).cleanupEmptyEditableTimesheet).toHaveBeenCalledWith(
583
+ tx,
584
+ 55,
585
+ );
586
+ expect((service as any).submitTimesheetForApproval).toHaveBeenCalledWith(
587
+ tx,
588
+ 77,
589
+ 7,
590
+ );
591
+ });
592
+
593
+ it("allows deleting entries from submitted timesheets until approval", async () => {
594
+ const tx = {
595
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
596
+ };
597
+
598
+ (service as any).prisma.$transaction.mockImplementation(
599
+ async (callback: (client: unknown) => unknown) => callback(tx),
600
+ );
601
+
602
+ jest
603
+ .spyOn(service as any, "getTimesheetEntryByIdForActor")
604
+ .mockResolvedValue({
605
+ id: 92,
606
+ timesheetId: 56,
607
+ collaboratorId: 7,
608
+ status: "submitted",
609
+ });
610
+ jest
611
+ .spyOn(service as any, "refreshTimesheetTotal")
612
+ .mockResolvedValue(undefined);
613
+ jest
614
+ .spyOn(service as any, "cleanupEmptyEditableTimesheet")
615
+ .mockResolvedValue(undefined);
616
+
617
+ await expect(service.removeTimesheetEntry(15, 92)).resolves.toEqual({
618
+ success: true,
619
+ });
620
+ });
621
+
622
+ it("rejects deleting entries from approved timesheets", async () => {
623
+ jest
624
+ .spyOn(service as any, "getTimesheetEntryByIdForActor")
625
+ .mockResolvedValue({
626
+ id: 94,
627
+ timesheetId: 57,
628
+ collaboratorId: 7,
629
+ status: "approved",
630
+ });
631
+
632
+ await expect(service.removeTimesheetEntry(15, 94)).rejects.toThrow(
633
+ BadRequestException,
634
+ );
635
+ });
636
+ });
637
+
638
+ describe("OperationsService task column activity", () => {
639
+ let service: OperationsService;
640
+ let prisma: { $transaction: jest.Mock };
641
+ let integrationApi: { publishEvent: jest.Mock };
642
+
643
+ const baseTask = {
644
+ id: 44,
645
+ name: "Implement board",
646
+ description: null,
647
+ priority: "medium",
648
+ status: "todo",
649
+ dueDate: null,
650
+ estimateHours: null,
651
+ position: 0,
652
+ tags: null,
653
+ assigneeCollaboratorId: null,
654
+ projectAssignmentId: null,
655
+ projectId: 11,
656
+ projectName: "Project Atlas",
657
+ projectCode: "OPS-11",
658
+ doingStartedAt: null,
659
+ totalDoingMinutes: 0,
660
+ deletedAt: null,
661
+ };
662
+
663
+ beforeEach(() => {
664
+ prisma = {
665
+ $transaction: jest.fn(),
666
+ };
667
+ integrationApi = {
668
+ publishEvent: jest.fn().mockResolvedValue(undefined),
669
+ };
670
+ service = new OperationsService(
671
+ prisma as any,
672
+ {} as any,
673
+ integrationApi as any,
674
+ {} as any,
675
+ {} as any,
676
+ new OperationsAccessService(),
677
+ );
678
+
679
+ jest.spyOn(service as any, "getActorContext").mockResolvedValue({
680
+ userId: 15,
681
+ roleSlugs: ["operations-collaborator"],
682
+ collaboratorId: 7,
683
+ collaboratorName: "Taylor Tester",
684
+ isDirector: false,
685
+ isSupervisor: false,
686
+ isCollaborator: true,
687
+ teamCollaboratorIds: [],
688
+ visibleCollaboratorIds: [7],
689
+ visibleProjectIds: [11],
690
+ });
691
+ jest
692
+ .spyOn(service as any, "assertProjectAccess")
693
+ .mockResolvedValue(undefined);
694
+ jest.spyOn(service as any, "getProjectBoardTask").mockResolvedValue({
695
+ ...baseTask,
696
+ assigneeCollaboratorId: 7,
697
+ status: "doing",
698
+ });
699
+ });
700
+
701
+ afterEach(() => {
702
+ jest.restoreAllMocks();
703
+ });
704
+
705
+ function mockTransaction() {
706
+ const tx = {
707
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
708
+ };
709
+ prisma.$transaction.mockImplementation(
710
+ async (callback: (client: unknown) => unknown) => callback(tx),
711
+ );
712
+ return tx;
713
+ }
714
+
715
+ it("records todo to doing and auto-assigns the actor", async () => {
716
+ const tx = mockTransaction();
717
+ jest
718
+ .spyOn(service as any, "getTaskRecordForActor")
719
+ .mockResolvedValue(baseTask);
720
+
721
+ await service.updateTask(15, 44, { status: "doing" } as any);
722
+
723
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(2);
724
+ expect(tx.$executeRawUnsafe.mock.calls[0][0]).toContain(
725
+ "doing_started_at = CASE",
726
+ );
727
+ expect(tx.$executeRawUnsafe.mock.calls[0][3]).toBe(7);
728
+ expect(tx.$executeRawUnsafe.mock.calls[0][15]).toBe(true);
729
+ expect(tx.$executeRawUnsafe.mock.calls[0][16]).toBe(false);
730
+ expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
731
+ "INSERT INTO operations_task_activity",
732
+ );
733
+ expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
734
+ '$3::"operations_task_activity_action_bd50457330_enum"',
735
+ );
736
+ expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
737
+ '$4::"operations_task_activity_from_status_3a69262de6_enum"',
738
+ );
739
+ expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
740
+ '$5::"operations_task_activity_to_status_1fd99321c8_enum"',
741
+ );
742
+ expect(tx.$executeRawUnsafe.mock.calls[1].slice(1)).toEqual([
743
+ 44,
744
+ 7,
745
+ "status_changed",
746
+ "todo",
747
+ "doing",
748
+ ]);
749
+ });
750
+
751
+ it("records doing to review and closes accumulated doing minutes", async () => {
752
+ const tx = mockTransaction();
753
+ jest.spyOn(service as any, "getTaskRecordForActor").mockResolvedValue({
754
+ ...baseTask,
755
+ status: "doing",
756
+ assigneeCollaboratorId: 9,
757
+ doingStartedAt: "2026-05-16T10:00:00.000Z",
758
+ totalDoingMinutes: 15,
759
+ });
760
+
761
+ await service.updateTask(15, 44, { status: "review" } as any);
762
+
763
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(2);
764
+ expect(tx.$executeRawUnsafe.mock.calls[0][3]).toBe(9);
765
+ expect(tx.$executeRawUnsafe.mock.calls[0][15]).toBe(false);
766
+ expect(tx.$executeRawUnsafe.mock.calls[0][16]).toBe(true);
767
+ expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
768
+ '$3::"operations_task_activity_action_bd50457330_enum"',
769
+ );
770
+ expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
771
+ '$4::"operations_task_activity_from_status_3a69262de6_enum"',
772
+ );
773
+ expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
774
+ '$5::"operations_task_activity_to_status_1fd99321c8_enum"',
775
+ );
776
+ expect(tx.$executeRawUnsafe.mock.calls[1].slice(1)).toEqual([
777
+ 44,
778
+ 7,
779
+ "status_changed",
780
+ "doing",
781
+ "review",
782
+ ]);
783
+ });
784
+
785
+ it("blocks moving advanced without a linked collaborator when no assignee exists", async () => {
786
+ prisma.$transaction.mockImplementation(
787
+ async (callback: (client: unknown) => unknown) =>
788
+ callback({ $executeRawUnsafe: jest.fn() }),
789
+ );
790
+ jest.spyOn(service as any, "getActorContext").mockResolvedValue({
791
+ userId: 15,
792
+ roleSlugs: ["admin"],
793
+ collaboratorId: null,
794
+ collaboratorName: null,
795
+ isDirector: true,
796
+ isSupervisor: true,
797
+ isCollaborator: true,
798
+ teamCollaboratorIds: [],
799
+ visibleCollaboratorIds: [],
800
+ visibleProjectIds: [],
801
+ });
802
+ jest
803
+ .spyOn(service as any, "getTaskRecordForActor")
804
+ .mockResolvedValue(baseTask);
805
+
806
+ await expect(
807
+ service.updateTask(15, 44, { status: "review" } as any),
808
+ ).rejects.toThrow(BadRequestException);
809
+ });
810
+
811
+ it("does not create activity when the status does not change", async () => {
812
+ const tx = mockTransaction();
813
+ jest
814
+ .spyOn(service as any, "getTaskRecordForActor")
815
+ .mockResolvedValue(baseTask);
816
+
817
+ await service.updateTask(15, 44, { status: "todo" } as any);
818
+
819
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(1);
820
+ expect(tx.$executeRawUnsafe.mock.calls[0][15]).toBe(false);
821
+ expect(tx.$executeRawUnsafe.mock.calls[0][16]).toBe(false);
822
+ });
823
+ });
824
+
825
+ describe("OperationsService approval side effects", () => {
826
+ let service: OperationsService;
827
+
828
+ beforeEach(() => {
829
+ service = new OperationsService(
830
+ {
831
+ $transaction: jest.fn(),
832
+ } as any,
833
+ {} as any,
834
+ {
835
+ publishEvent: jest.fn().mockResolvedValue(undefined),
836
+ } as any,
837
+ {} as any,
838
+ {} as any,
839
+ new OperationsAccessService(),
840
+ );
841
+
842
+ jest.spyOn(service as any, "getActorContext").mockResolvedValue({
843
+ userId: 22,
844
+ roleSlugs: ["admin-operations-supervisor"],
845
+ collaboratorId: 18,
846
+ collaboratorName: "Sam Supervisor",
847
+ isDirector: false,
848
+ isSupervisor: true,
849
+ isCollaborator: false,
850
+ teamCollaboratorIds: [7],
851
+ visibleCollaboratorIds: [7, 18],
852
+ visibleProjectIds: [11],
853
+ });
854
+ });
855
+
856
+ afterEach(() => {
857
+ jest.restoreAllMocks();
858
+ });
859
+
860
+ it("applies approved permanent schedule adjustments to the active collaborator weekly schedule", async () => {
861
+ const tx = {
862
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
863
+ $queryRawUnsafe: jest
864
+ .fn()
865
+ .mockResolvedValueOnce([
866
+ {
867
+ collaboratorId: 7,
868
+ requestScope: "permanent",
869
+ },
870
+ ])
871
+ .mockResolvedValueOnce([
872
+ {
873
+ weekday: "monday",
874
+ isWorkingDay: true,
875
+ startTime: "08:00",
876
+ endTime: "17:00",
877
+ breakMinutes: 45,
878
+ },
879
+ {
880
+ weekday: "friday",
881
+ isWorkingDay: false,
882
+ startTime: null,
883
+ endTime: null,
884
+ breakMinutes: 0,
885
+ },
886
+ ]),
887
+ };
888
+
889
+ (service as any).prisma.$transaction.mockImplementation(
890
+ async (callback: (client: unknown) => unknown) => callback(tx),
891
+ );
892
+
893
+ jest
894
+ .spyOn(service as any, "querySingle")
895
+ .mockResolvedValueOnce({
896
+ id: 81,
897
+ targetType: "schedule_adjustment_request",
898
+ targetId: 400,
899
+ requesterCollaboratorId: 7,
900
+ approverCollaboratorId: 18,
901
+ status: "pending",
902
+ })
903
+ .mockResolvedValueOnce({
904
+ id: 81,
905
+ targetType: "schedule_adjustment_request",
906
+ targetId: 400,
907
+ status: "approved",
908
+ decidedAt: "2026-04-11T12:00:00.000Z",
909
+ decisionNote: "Approved for the new weekly schedule.",
910
+ });
911
+ jest
912
+ .spyOn(service as any, "insertApprovalHistory")
913
+ .mockResolvedValue(undefined);
914
+
915
+ await expect(
916
+ service.approve(22, 81, {
917
+ note: "Approved for the new weekly schedule.",
918
+ }),
919
+ ).resolves.toEqual(
920
+ expect.objectContaining({
921
+ id: 81,
922
+ status: "approved",
923
+ }),
924
+ );
925
+
926
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
927
+ expect.stringContaining("UPDATE operations_schedule_adjustment_request"),
928
+ "approved",
929
+ 18,
930
+ 400,
931
+ );
932
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
933
+ expect.stringContaining("UPDATE operations_collaborator_schedule_day"),
934
+ 7,
935
+ );
936
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
937
+ expect.stringContaining(
938
+ "INSERT INTO operations_collaborator_schedule_day",
939
+ ),
940
+ 7,
941
+ "monday",
942
+ true,
943
+ "08:00",
944
+ "17:00",
945
+ 45,
946
+ );
947
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
948
+ expect.stringContaining(
949
+ "INSERT INTO operations_collaborator_schedule_day",
950
+ ),
951
+ 7,
952
+ "friday",
953
+ false,
954
+ null,
955
+ null,
956
+ 0,
957
+ );
958
+ });
959
+
960
+ it("keeps the collaborator weekly schedule unchanged for approved temporary adjustments", async () => {
961
+ const tx = {
962
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
963
+ $queryRawUnsafe: jest.fn().mockResolvedValueOnce([
964
+ {
965
+ collaboratorId: 7,
966
+ requestScope: "temporary",
967
+ },
968
+ ]),
969
+ };
970
+
971
+ (service as any).prisma.$transaction.mockImplementation(
972
+ async (callback: (client: unknown) => unknown) => callback(tx),
973
+ );
974
+
975
+ jest
976
+ .spyOn(service as any, "querySingle")
977
+ .mockResolvedValueOnce({
978
+ id: 82,
979
+ targetType: "schedule_adjustment_request",
980
+ targetId: 401,
981
+ requesterCollaboratorId: 7,
982
+ approverCollaboratorId: 18,
983
+ status: "pending",
984
+ })
985
+ .mockResolvedValueOnce({
986
+ id: 82,
987
+ targetType: "schedule_adjustment_request",
988
+ targetId: 401,
989
+ status: "approved",
990
+ decidedAt: "2026-04-11T12:05:00.000Z",
991
+ decisionNote: "Approved as a temporary exception.",
992
+ });
993
+ jest
994
+ .spyOn(service as any, "insertApprovalHistory")
995
+ .mockResolvedValue(undefined);
996
+
997
+ await service.approve(22, 82, {
998
+ note: "Approved as a temporary exception.",
999
+ });
1000
+
1001
+ expect(tx.$executeRawUnsafe).not.toHaveBeenCalledWith(
1002
+ expect.stringContaining("UPDATE operations_collaborator_schedule_day"),
1003
+ 7,
1004
+ );
1005
+ });
1006
+ });