@hed-hog/operations 0.0.294 → 0.0.296

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 (126) hide show
  1. package/dist/operations.controller.d.ts +415 -0
  2. package/dist/operations.controller.d.ts.map +1 -0
  3. package/dist/operations.controller.js +333 -0
  4. package/dist/operations.controller.js.map +1 -0
  5. package/dist/operations.module.d.ts.map +1 -1
  6. package/dist/operations.module.js +4 -3
  7. package/dist/operations.module.js.map +1 -1
  8. package/dist/operations.service.d.ts +589 -153
  9. package/dist/operations.service.d.ts.map +1 -1
  10. package/dist/operations.service.js +2229 -100
  11. package/dist/operations.service.js.map +1 -1
  12. package/hedhog/data/menu.yaml +198 -251
  13. package/hedhog/data/role.yaml +23 -14
  14. package/hedhog/data/route.yaml +317 -143
  15. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +310 -0
  16. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +631 -0
  17. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +132 -0
  18. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +558 -0
  19. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +291 -0
  20. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +689 -0
  21. package/hedhog/frontend/app/_lib/api.ts.ejs +32 -0
  22. package/hedhog/frontend/app/_lib/hooks/use-operations-access.ts.ejs +44 -0
  23. package/hedhog/frontend/app/_lib/types.ts.ejs +360 -0
  24. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +129 -25
  25. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +14 -0
  26. package/hedhog/frontend/app/approvals/page.tsx.ejs +386 -147
  27. package/hedhog/frontend/app/collaborators/[id]/edit/page.tsx.ejs +11 -0
  28. package/hedhog/frontend/app/collaborators/[id]/page.tsx.ejs +11 -0
  29. package/hedhog/frontend/app/collaborators/new/page.tsx.ejs +5 -0
  30. package/hedhog/frontend/app/collaborators/page.tsx.ejs +261 -0
  31. package/hedhog/frontend/app/contracts/[id]/edit/page.tsx.ejs +11 -0
  32. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +11 -108
  33. package/hedhog/frontend/app/contracts/new/page.tsx.ejs +17 -0
  34. package/hedhog/frontend/app/contracts/page.tsx.ejs +262 -181
  35. package/hedhog/frontend/app/page.tsx.ejs +319 -177
  36. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +11 -0
  37. package/hedhog/frontend/app/projects/[id]/page.tsx.ejs +11 -936
  38. package/hedhog/frontend/app/projects/new/page.tsx.ejs +5 -0
  39. package/hedhog/frontend/app/projects/page.tsx.ejs +236 -1074
  40. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +418 -0
  41. package/hedhog/frontend/app/team/page.tsx.ejs +339 -0
  42. package/hedhog/frontend/app/time-off/page.tsx.ejs +328 -0
  43. package/hedhog/frontend/app/timesheets/page.tsx.ejs +636 -126
  44. package/hedhog/frontend/messages/en.json +648 -454
  45. package/hedhog/frontend/messages/pt.json +647 -454
  46. package/hedhog/table/operations_approval.yaml +49 -0
  47. package/hedhog/table/operations_approval_history.yaml +29 -0
  48. package/hedhog/table/{operations_employee.yaml → operations_collaborator.yaml} +67 -64
  49. package/hedhog/table/operations_collaborator_schedule_day.yaml +34 -0
  50. package/hedhog/table/operations_contract.yaml +100 -48
  51. package/hedhog/table/operations_contract_document.yaml +39 -0
  52. package/hedhog/table/operations_contract_financial_term.yaml +40 -0
  53. package/hedhog/table/operations_contract_history.yaml +27 -0
  54. package/hedhog/table/operations_contract_party.yaml +46 -0
  55. package/hedhog/table/operations_contract_revision.yaml +38 -0
  56. package/hedhog/table/operations_contract_signature.yaml +38 -0
  57. package/hedhog/table/operations_project.yaml +54 -50
  58. package/hedhog/table/{operations_allocation.yaml → operations_project_assignment.yaml} +55 -52
  59. package/hedhog/table/operations_schedule_adjustment_day.yaml +34 -0
  60. package/hedhog/table/operations_schedule_adjustment_request.yaml +53 -0
  61. package/hedhog/table/operations_time_off_request.yaml +57 -0
  62. package/hedhog/table/operations_timesheet.yaml +41 -36
  63. package/hedhog/table/operations_timesheet_entry.yaml +40 -50
  64. package/package.json +7 -6
  65. package/src/operations.controller.ts +182 -0
  66. package/src/operations.module.ts +22 -21
  67. package/src/operations.service.ts +3595 -137
  68. package/hedhog/data/operations_career_level.yaml +0 -102
  69. package/hedhog/data/operations_career_track.yaml +0 -8
  70. package/hedhog/data/operations_certification.yaml +0 -38
  71. package/hedhog/data/operations_evaluation_cycle.yaml +0 -18
  72. package/hedhog/data/operations_performance_criterion.yaml +0 -48
  73. package/hedhog/frontend/app/_components/allocation-calendar.tsx.ejs +0 -56
  74. package/hedhog/frontend/app/_components/kanban-board.tsx.ejs +0 -626
  75. package/hedhog/frontend/app/_components/timesheet-entry-dialog.tsx.ejs +0 -142
  76. package/hedhog/frontend/app/_lib/hooks/use-operations-data.ts.ejs +0 -41
  77. package/hedhog/frontend/app/_lib/hooks/use-operations-growth-data.ts.ejs +0 -63
  78. package/hedhog/frontend/app/_lib/mocks/allocations.mock.ts.ejs +0 -74
  79. package/hedhog/frontend/app/_lib/mocks/contracts.mock.ts.ejs +0 -74
  80. package/hedhog/frontend/app/_lib/mocks/operations-growth.mock.ts.ejs +0 -824
  81. package/hedhog/frontend/app/_lib/mocks/projects.mock.ts.ejs +0 -455
  82. package/hedhog/frontend/app/_lib/mocks/tasks.mock.ts.ejs +0 -117
  83. package/hedhog/frontend/app/_lib/mocks/timesheets.mock.ts.ejs +0 -84
  84. package/hedhog/frontend/app/_lib/mocks/users.mock.ts.ejs +0 -67
  85. package/hedhog/frontend/app/_lib/services/contracts.service.ts.ejs +0 -10
  86. package/hedhog/frontend/app/_lib/services/operations-growth.service.ts.ejs +0 -31
  87. package/hedhog/frontend/app/_lib/services/projects.service.ts.ejs +0 -10
  88. package/hedhog/frontend/app/_lib/services/tasks.service.ts.ejs +0 -10
  89. package/hedhog/frontend/app/_lib/services/timesheets.service.ts.ejs +0 -10
  90. package/hedhog/frontend/app/_lib/types/operations-growth.ts.ejs +0 -209
  91. package/hedhog/frontend/app/_lib/types/operations.ts.ejs +0 -156
  92. package/hedhog/frontend/app/_lib/utils/growth.ts.ejs +0 -62
  93. package/hedhog/frontend/app/_lib/utils/metrics.ts.ejs +0 -103
  94. package/hedhog/frontend/app/_lib/utils/status.ts.ejs +0 -80
  95. package/hedhog/frontend/app/allocations/page.tsx.ejs +0 -155
  96. package/hedhog/frontend/app/career/page.tsx.ejs +0 -143
  97. package/hedhog/frontend/app/certifications/page.tsx.ejs +0 -202
  98. package/hedhog/frontend/app/evaluations/page.tsx.ejs +0 -278
  99. package/hedhog/frontend/app/goals/page.tsx.ejs +0 -171
  100. package/hedhog/frontend/app/growth/page.tsx.ejs +0 -288
  101. package/hedhog/frontend/app/manager/page.tsx.ejs +0 -175
  102. package/hedhog/frontend/app/rewards/page.tsx.ejs +0 -196
  103. package/hedhog/frontend/app/tasks/page.tsx.ejs +0 -999
  104. package/hedhog/table/operations_calibration_item.yaml +0 -61
  105. package/hedhog/table/operations_calibration_session.yaml +0 -25
  106. package/hedhog/table/operations_career_level.yaml +0 -75
  107. package/hedhog/table/operations_career_track.yaml +0 -21
  108. package/hedhog/table/operations_certification.yaml +0 -48
  109. package/hedhog/table/operations_employee_certification.yaml +0 -43
  110. package/hedhog/table/operations_employee_connect.yaml +0 -61
  111. package/hedhog/table/operations_employee_evaluation.yaml +0 -113
  112. package/hedhog/table/operations_employee_evaluation_item.yaml +0 -39
  113. package/hedhog/table/operations_employee_profile.yaml +0 -80
  114. package/hedhog/table/operations_employee_skill_matrix.yaml +0 -30
  115. package/hedhog/table/operations_evaluation_cycle.yaml +0 -31
  116. package/hedhog/table/operations_goal.yaml +0 -67
  117. package/hedhog/table/operations_goal_progress.yaml +0 -31
  118. package/hedhog/table/operations_performance_criterion.yaml +0 -29
  119. package/hedhog/table/operations_promotion_readiness.yaml +0 -49
  120. package/hedhog/table/operations_promotion_recommendation.yaml +0 -63
  121. package/hedhog/table/operations_public_recognition.yaml +0 -46
  122. package/hedhog/table/operations_reward.yaml +0 -100
  123. package/hedhog/table/operations_score_event.yaml +0 -81
  124. package/hedhog/table/operations_task.yaml +0 -60
  125. package/src/operations-data.controller.ts +0 -54
  126. package/src/operations-growth.controller.ts +0 -44
@@ -1,137 +1,3595 @@
1
- import { Injectable } from '@nestjs/common';
2
-
3
- const operationalCollections = {
4
- contracts: [
5
- { id: 1, code: 'CTR-001', name: 'Northwind Retainer', status: 'active' },
6
- { id: 2, code: 'CTR-002', name: 'BluePeak Migration', status: 'renewal' },
7
- ],
8
- projects: [
9
- { id: 1, code: 'PRJ-OPS-1', name: 'Phoenix Platform Rollout', status: 'active' },
10
- { id: 2, code: 'PRJ-OPS-2', name: 'Northwind Service Desk', status: 'at_risk' },
11
- ],
12
- allocations: [
13
- { id: 1, employeeId: 1, projectId: 1, weeklyHours: 30, allocationPercent: 75 },
14
- { id: 2, employeeId: 2, projectId: 2, weeklyHours: 20, allocationPercent: 50 },
15
- ],
16
- tasks: [
17
- { id: 1, title: 'Deploy onboarding workflow', status: 'review' },
18
- { id: 2, title: 'Refine support KPI dashboard', status: 'in_progress' },
19
- ],
20
- timesheets: [
21
- { id: 1, employeeId: 1, status: 'submitted', cycleStartDate: '2026-03-09' },
22
- { id: 2, employeeId: 2, status: 'approved', cycleStartDate: '2026-03-09' },
23
- ],
24
- approvals: [
25
- { id: 1, employeeId: 1, status: 'pending', hours: 8 },
26
- { id: 2, employeeId: 3, status: 'pending', hours: 6.5 },
27
- ],
28
- };
29
-
30
- const growthCollections = {
31
- growth: {
32
- topSignals: ['score_events', 'career_tracks', 'goals', 'recognitions'],
33
- note: 'Foundation endpoint for the employee growth dashboard.',
34
- },
35
- evaluations: [
36
- { id: 1, employeeId: 1, cycle: '2026 H1 Growth Cycle', status: 'finalized', generatedScore: 84 },
37
- { id: 2, employeeId: 2, cycle: '2026 Delivery Sprint Review', status: 'submitted', generatedScore: 72 },
38
- ],
39
- goals: [
40
- { id: 1, employeeId: 1, title: 'Reduce rework under 5%', status: 'active', progressPercent: 68 },
41
- { id: 2, employeeId: 2, title: 'Close Scrum certification', status: 'at_risk', progressPercent: 35 },
42
- ],
43
- certifications: [
44
- { id: 1, slug: 'scrum-master-foundations', status: 'achieved' },
45
- { id: 2, slug: 'advanced-quality-assurance', status: 'planned' },
46
- ],
47
- rewards: [
48
- { id: 1, employeeId: 1, rewardType: 'bonus', status: 'granted' },
49
- { id: 2, employeeId: 3, rewardType: 'recognition', status: 'granted' },
50
- ],
51
- career: [
52
- { id: 1, track: 'Delivery', currentLevel: 'Consultant', nextLevel: 'Senior Consultant' },
53
- { id: 2, track: 'Specialist', currentLevel: 'Specialist II', nextLevel: 'Principal Specialist' },
54
- ],
55
- manager: {
56
- summary: {
57
- promotionEligible: 2,
58
- goalsAtRisk: 3,
59
- pendingCertifications: 4,
60
- },
61
- },
62
- };
63
-
64
- @Injectable()
65
- export class OperationsService {
66
- // TODO: Replace static collections with Prisma queries after the first schema apply.
67
- // TODO: Promotion must not be derived from score alone; score remains only a support signal.
68
- // TODO: Certifications are supporting evidence and do not guarantee promotion on their own.
69
- // TODO: Future readiness logic must consider individual, team, and org impact, plus time operating at the next level and calibration outcomes.
70
- getDashboard() {
71
- return {
72
- module: 'operations',
73
- operationalCollections,
74
- growthCollections,
75
- };
76
- }
77
-
78
- listContracts() {
79
- return operationalCollections.contracts;
80
- }
81
-
82
- getContractById(id: number) {
83
- return operationalCollections.contracts.find((item) => item.id === id) ?? null;
84
- }
85
-
86
- listProjects() {
87
- return operationalCollections.projects;
88
- }
89
-
90
- getProjectById(id: number) {
91
- return operationalCollections.projects.find((item) => item.id === id) ?? null;
92
- }
93
-
94
- listAllocations() {
95
- return operationalCollections.allocations;
96
- }
97
-
98
- listTasks() {
99
- return operationalCollections.tasks;
100
- }
101
-
102
- listTimesheets() {
103
- return operationalCollections.timesheets;
104
- }
105
-
106
- listApprovals() {
107
- return operationalCollections.approvals;
108
- }
109
-
110
- getGrowthDashboard() {
111
- return growthCollections.growth;
112
- }
113
-
114
- listEvaluations() {
115
- return growthCollections.evaluations;
116
- }
117
-
118
- listGoals() {
119
- return growthCollections.goals;
120
- }
121
-
122
- listCertifications() {
123
- return growthCollections.certifications;
124
- }
125
-
126
- listRewards() {
127
- return growthCollections.rewards;
128
- }
129
-
130
- listCareerPaths() {
131
- return growthCollections.career;
132
- }
133
-
134
- getManagerOverview() {
135
- return growthCollections.manager;
136
- }
137
- }
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { IntegrationDeveloperApiService } from '@hed-hog/core';
3
+ import {
4
+ BadRequestException,
5
+ ForbiddenException,
6
+ Injectable,
7
+ NotFoundException,
8
+ } from '@nestjs/common';
9
+
10
+ const COLLABORATOR_ROLE = 'admin-operations-collaborator';
11
+ const SUPERVISOR_ROLE = 'admin-operations-supervisor';
12
+ const DIRECTOR_ROLE = 'admin-operations-director';
13
+
14
+ type ApprovalAction = 'approve' | 'reject';
15
+ type ApprovalTargetType =
16
+ | 'timesheet'
17
+ | 'time_off_request'
18
+ | 'schedule_adjustment_request';
19
+
20
+ type ActorContext = {
21
+ userId: number;
22
+ roleSlugs: string[];
23
+ collaboratorId: number | null;
24
+ collaboratorName: string | null;
25
+ isDirector: boolean;
26
+ isSupervisor: boolean;
27
+ isCollaborator: boolean;
28
+ teamCollaboratorIds: number[];
29
+ visibleCollaboratorIds: number[];
30
+ visibleProjectIds: number[];
31
+ };
32
+
33
+ type CollaboratorPayload = {
34
+ userId: number;
35
+ code: string;
36
+ displayName: string;
37
+ collaboratorType?: 'clt' | 'pj' | 'freelancer' | 'intern' | 'other';
38
+ department?: string | null;
39
+ title?: string | null;
40
+ levelLabel?: string | null;
41
+ supervisorCollaboratorId?: number | null;
42
+ weeklyCapacityHours?: number | null;
43
+ status?: 'active' | 'on_leave' | 'inactive';
44
+ joinedAt?: string | null;
45
+ leftAt?: string | null;
46
+ compensationAmount?: number | null;
47
+ contractDescription?: string | null;
48
+ autoGenerateContractDraft?: boolean;
49
+ weeklySchedule?: Array<{
50
+ weekday:
51
+ | 'monday'
52
+ | 'tuesday'
53
+ | 'wednesday'
54
+ | 'thursday'
55
+ | 'friday'
56
+ | 'saturday'
57
+ | 'sunday';
58
+ isWorkingDay?: boolean;
59
+ startTime?: string | null;
60
+ endTime?: string | null;
61
+ breakMinutes?: number | null;
62
+ }>;
63
+ notes?: string | null;
64
+ };
65
+
66
+ type ContractPayload = {
67
+ code: string;
68
+ name: string;
69
+ clientName: string;
70
+ contractCategory?:
71
+ | 'employee'
72
+ | 'contractor'
73
+ | 'client'
74
+ | 'supplier'
75
+ | 'vendor'
76
+ | 'partner'
77
+ | 'internal'
78
+ | 'other';
79
+ contractType?:
80
+ | 'clt'
81
+ | 'pj'
82
+ | 'freelancer_agreement'
83
+ | 'service_agreement'
84
+ | 'fixed_term'
85
+ | 'recurring_service'
86
+ | 'nda'
87
+ | 'amendment'
88
+ | 'addendum'
89
+ | 'other';
90
+ billingModel?:
91
+ | 'time_and_material'
92
+ | 'monthly_retainer'
93
+ | 'fixed_price';
94
+ signatureStatus?:
95
+ | 'not_started'
96
+ | 'pending'
97
+ | 'partially_signed'
98
+ | 'signed'
99
+ | 'expired';
100
+ isActive?: boolean;
101
+ accountManagerCollaboratorId?: number | null;
102
+ relatedCollaboratorId?: number | null;
103
+ originType?: 'manual' | 'employee_hiring' | 'client_project';
104
+ originId?: number | null;
105
+ startDate: string;
106
+ endDate?: string | null;
107
+ signedAt?: string | null;
108
+ effectiveDate?: string | null;
109
+ budgetAmount?: number | null;
110
+ monthlyHourCap?: number | null;
111
+ status?:
112
+ | 'draft'
113
+ | 'under_review'
114
+ | 'active'
115
+ | 'renewal'
116
+ | 'expired'
117
+ | 'closed'
118
+ | 'archived';
119
+ description?: string | null;
120
+ contentHtml?: string | null;
121
+ parties?: Array<{
122
+ partyRole?:
123
+ | 'employee'
124
+ | 'employer'
125
+ | 'client'
126
+ | 'supplier'
127
+ | 'vendor'
128
+ | 'partner'
129
+ | 'witness'
130
+ | 'internal_owner'
131
+ | 'other';
132
+ partyType?: 'individual' | 'company' | 'internal_team' | 'other';
133
+ displayName: string;
134
+ documentNumber?: string | null;
135
+ email?: string | null;
136
+ phone?: string | null;
137
+ isPrimary?: boolean;
138
+ }>;
139
+ signatures?: Array<{
140
+ signerName: string;
141
+ signerRole?: string | null;
142
+ signerEmail?: string | null;
143
+ status?: 'pending' | 'signed' | 'rejected';
144
+ signedAt?: string | null;
145
+ }>;
146
+ financialTerms?: Array<{
147
+ termType?: 'value' | 'payment' | 'revenue' | 'fine' | 'other';
148
+ label: string;
149
+ amount: number;
150
+ recurrence?: 'one_time' | 'monthly' | 'quarterly' | 'yearly' | 'other';
151
+ dueDay?: number | null;
152
+ notes?: string | null;
153
+ }>;
154
+ revisions?: Array<{
155
+ revisionType?: 'amendment' | 'renewal' | 'revision' | 'addendum' | 'other';
156
+ title: string;
157
+ effectiveDate?: string | null;
158
+ status?: 'draft' | 'active' | 'completed' | 'cancelled';
159
+ summary?: string | null;
160
+ }>;
161
+ replaceUploadedPdfDocument?: {
162
+ fileName: string;
163
+ mimeType: string;
164
+ fileContentBase64: string;
165
+ notes?: string | null;
166
+ } | null;
167
+ };
168
+
169
+ type ProjectPayload = {
170
+ contractId?: number | null;
171
+ managerCollaboratorId?: number | null;
172
+ code: string;
173
+ name: string;
174
+ clientName?: string | null;
175
+ summary?: string | null;
176
+ status?:
177
+ | 'planning'
178
+ | 'active'
179
+ | 'at_risk'
180
+ | 'paused'
181
+ | 'completed'
182
+ | 'archived';
183
+ progressPercent?: number | null;
184
+ deliveryModel?:
185
+ | 'dedicated_team'
186
+ | 'shared_team'
187
+ | 'project_delivery'
188
+ | 'support';
189
+ budgetAmount?: number | null;
190
+ startDate?: string | null;
191
+ endDate?: string | null;
192
+ billingModel?:
193
+ | 'time_and_material'
194
+ | 'monthly_retainer'
195
+ | 'fixed_price';
196
+ monthlyHourCap?: number | null;
197
+ contractCode?: string | null;
198
+ contractName?: string | null;
199
+ contractDescription?: string | null;
200
+ autoGenerateContractDraft?: boolean;
201
+ teamAssignments?: Array<{
202
+ collaboratorId: number;
203
+ roleLabel?: string | null;
204
+ allocationPercent?: number | null;
205
+ weeklyHours?: number | null;
206
+ isBillable?: boolean;
207
+ startDate?: string | null;
208
+ endDate?: string | null;
209
+ status?: 'planned' | 'active' | 'completed' | 'cancelled';
210
+ }>;
211
+ };
212
+
213
+ type TimesheetEntryPayload = {
214
+ projectAssignmentId?: number | null;
215
+ activityLabel?: string | null;
216
+ workDate: string;
217
+ hours: number;
218
+ description?: string | null;
219
+ };
220
+
221
+ type TimesheetPayload = {
222
+ collaboratorId?: number | null;
223
+ weekStartDate: string;
224
+ weekEndDate: string;
225
+ notes?: string | null;
226
+ entries?: TimesheetEntryPayload[];
227
+ };
228
+
229
+ type TimeOffPayload = {
230
+ collaboratorId?: number | null;
231
+ requestType?:
232
+ | 'vacation'
233
+ | 'personal_time'
234
+ | 'sick_leave'
235
+ | 'unpaid_leave'
236
+ | 'other';
237
+ startDate: string;
238
+ endDate: string;
239
+ totalDays?: number | null;
240
+ reason?: string | null;
241
+ };
242
+
243
+ type ScheduleAdjustmentDayPayload = {
244
+ weekday:
245
+ | 'monday'
246
+ | 'tuesday'
247
+ | 'wednesday'
248
+ | 'thursday'
249
+ | 'friday'
250
+ | 'saturday'
251
+ | 'sunday';
252
+ isWorkingDay?: boolean;
253
+ startTime?: string | null;
254
+ endTime?: string | null;
255
+ breakMinutes?: number | null;
256
+ };
257
+
258
+ type ScheduleAdjustmentPayload = {
259
+ collaboratorId?: number | null;
260
+ requestScope?: 'temporary' | 'permanent';
261
+ effectiveStartDate: string;
262
+ effectiveEndDate?: string | null;
263
+ reason?: string | null;
264
+ days: ScheduleAdjustmentDayPayload[];
265
+ };
266
+
267
+ type DecisionPayload = {
268
+ note?: string | null;
269
+ };
270
+
271
+ type PublishAccountsPayableReferencePayload = {
272
+ sourceEntityId: string;
273
+ sourceEntityType?: string;
274
+ personId: number;
275
+ dueDate: string;
276
+ totalAmount: number;
277
+ documentNumber: string;
278
+ description?: string | null;
279
+ paymentChannel?: string | null;
280
+ financeCategoryId?: number | null;
281
+ costCenterId?: number | null;
282
+ locale?: string;
283
+ };
284
+
285
+ @Injectable()
286
+ export class OperationsService {
287
+ constructor(
288
+ private readonly prisma: PrismaService,
289
+ private readonly integrationApi: IntegrationDeveloperApiService,
290
+ ) {}
291
+
292
+ async getDashboard(userId: number) {
293
+ const actor = await this.getActorContext(userId);
294
+ const projectFilter = this.buildIdFilter(
295
+ actor.visibleProjectIds,
296
+ 'p.id',
297
+ actor.isDirector
298
+ );
299
+ const collaboratorFilter = this.buildIdFilter(
300
+ actor.visibleCollaboratorIds,
301
+ 't.collaborator_id',
302
+ actor.isDirector
303
+ );
304
+ const timeOffFilter = this.buildIdFilter(
305
+ actor.visibleCollaboratorIds,
306
+ 'tor.collaborator_id',
307
+ actor.isDirector
308
+ );
309
+ const scheduleFilter = this.buildIdFilter(
310
+ actor.visibleCollaboratorIds,
311
+ 'sar.collaborator_id',
312
+ actor.isDirector
313
+ );
314
+ const approvalFilter = actor.isDirector
315
+ ? { clause: 'a.deleted_at IS NULL', params: [] as unknown[] }
316
+ : actor.collaboratorId
317
+ ? {
318
+ clause:
319
+ 'a.deleted_at IS NULL AND a.approver_collaborator_id = $1',
320
+ params: [actor.collaboratorId] as unknown[],
321
+ }
322
+ : { clause: '1 = 0', params: [] as unknown[] };
323
+
324
+ const [projects, timesheets, timeOff, schedules, approvals, recentTimesheets] =
325
+ await Promise.all([
326
+ this.querySingle<{ total: string; active: string }>(
327
+ `SELECT COUNT(*)::text AS total,
328
+ COUNT(*) FILTER (WHERE status = 'active')::text AS active
329
+ FROM operations_project p
330
+ WHERE p.deleted_at IS NULL AND ${projectFilter.clause}`,
331
+ projectFilter.params
332
+ ),
333
+ this.querySingle<{ total: string; submitted: string }>(
334
+ `SELECT COUNT(*)::text AS total,
335
+ COUNT(*) FILTER (WHERE status = 'submitted')::text AS submitted
336
+ FROM operations_timesheet t
337
+ WHERE t.deleted_at IS NULL AND ${collaboratorFilter.clause}`,
338
+ collaboratorFilter.params
339
+ ),
340
+ this.querySingle<{ total: string; submitted: string }>(
341
+ `SELECT COUNT(*)::text AS total,
342
+ COUNT(*) FILTER (WHERE status = 'submitted')::text AS submitted
343
+ FROM operations_time_off_request tor
344
+ WHERE tor.deleted_at IS NULL AND ${timeOffFilter.clause}`,
345
+ timeOffFilter.params
346
+ ),
347
+ this.querySingle<{ total: string; submitted: string }>(
348
+ `SELECT COUNT(*)::text AS total,
349
+ COUNT(*) FILTER (WHERE status = 'submitted')::text AS submitted
350
+ FROM operations_schedule_adjustment_request sar
351
+ WHERE sar.deleted_at IS NULL AND ${scheduleFilter.clause}`,
352
+ scheduleFilter.params
353
+ ),
354
+ this.querySingle<{ pending: string }>(
355
+ `SELECT COUNT(*) FILTER (WHERE status = 'pending')::text AS pending
356
+ FROM operations_approval a
357
+ WHERE ${approvalFilter.clause}`,
358
+ approvalFilter.params
359
+ ),
360
+ this.queryRows<{
361
+ id: number;
362
+ collaboratorName: string;
363
+ weekStartDate: string;
364
+ weekEndDate: string;
365
+ totalHours: number | null;
366
+ status: string;
367
+ }>(
368
+ `SELECT t.id,
369
+ c.display_name AS "collaboratorName",
370
+ t.week_start_date AS "weekStartDate",
371
+ t.week_end_date AS "weekEndDate",
372
+ t.total_hours AS "totalHours",
373
+ t.status
374
+ FROM operations_timesheet t
375
+ JOIN operations_collaborator c ON c.id = t.collaborator_id
376
+ WHERE t.deleted_at IS NULL AND ${collaboratorFilter.clause}
377
+ ORDER BY COALESCE(t.submitted_at, t.updated_at) DESC
378
+ LIMIT 5`,
379
+ collaboratorFilter.params
380
+ ),
381
+ ]);
382
+
383
+ return {
384
+ actor: {
385
+ roleScope: actor.isDirector
386
+ ? 'full'
387
+ : actor.isSupervisor
388
+ ? 'team'
389
+ : 'self',
390
+ collaboratorId: actor.collaboratorId,
391
+ collaboratorName: actor.collaboratorName,
392
+ teamSize: actor.teamCollaboratorIds.length,
393
+ },
394
+ cards: {
395
+ projectsTotal: Number(projects?.total ?? 0),
396
+ activeProjects: Number(projects?.active ?? 0),
397
+ visibleTimesheets: Number(timesheets?.total ?? 0),
398
+ pendingTimesheets: Number(timesheets?.submitted ?? 0),
399
+ timeOffRequests: Number(timeOff?.total ?? 0),
400
+ scheduleAdjustmentRequests: Number(schedules?.total ?? 0),
401
+ pendingApprovals: Number(approvals?.pending ?? 0),
402
+ },
403
+ recentTimesheets,
404
+ };
405
+ }
406
+
407
+ async listCollaborators(userId: number) {
408
+ const actor = await this.getActorContext(userId);
409
+ this.ensureCollaborator(actor);
410
+ const filter = this.buildIdFilter(
411
+ actor.visibleCollaboratorIds,
412
+ 'c.id',
413
+ actor.isDirector
414
+ );
415
+
416
+ return this.queryRows(
417
+ `SELECT c.id,
418
+ c.user_id AS "userId",
419
+ c.code,
420
+ c.collaborator_type AS "collaboratorType",
421
+ c.display_name AS "displayName",
422
+ c.department,
423
+ c.title,
424
+ c.level_label AS "levelLabel",
425
+ c.weekly_capacity_hours AS "weeklyCapacityHours",
426
+ c.status,
427
+ c.joined_at AS "joinedAt",
428
+ c.left_at AS "leftAt",
429
+ c.notes,
430
+ s.id AS "supervisorId",
431
+ s.display_name AS "supervisorName",
432
+ hiring_contract.id AS "contractId",
433
+ hiring_contract.status AS "contractStatus",
434
+ COUNT(DISTINCT pa.id)::int AS "activeAssignments"
435
+ FROM operations_collaborator c
436
+ LEFT JOIN operations_collaborator s
437
+ ON s.id = c.supervisor_collaborator_id
438
+ LEFT JOIN operations_project_assignment pa
439
+ ON pa.collaborator_id = c.id
440
+ AND pa.deleted_at IS NULL
441
+ AND pa.status IN ('planned', 'active')
442
+ LEFT JOIN LATERAL (
443
+ SELECT oc.id, oc.status
444
+ FROM operations_contract oc
445
+ WHERE oc.related_collaborator_id = c.id
446
+ AND oc.deleted_at IS NULL
447
+ ORDER BY CASE WHEN oc.origin_type = 'employee_hiring' THEN 0 ELSE 1 END,
448
+ oc.created_at DESC
449
+ LIMIT 1
450
+ ) hiring_contract ON TRUE
451
+ WHERE c.deleted_at IS NULL
452
+ AND ${filter.clause}
453
+ GROUP BY c.id, s.id, hiring_contract.id, hiring_contract.status
454
+ ORDER BY c.display_name ASC`,
455
+ filter.params
456
+ );
457
+ }
458
+
459
+ async getMyCollaborator(userId: number) {
460
+ const actor = await this.getActorContext(userId);
461
+ if (!actor.collaboratorId) {
462
+ throw new NotFoundException('No collaborator profile linked to this user.');
463
+ }
464
+ return this.getCollaboratorByIdForUser(userId, actor.collaboratorId);
465
+ }
466
+
467
+ async getCollaboratorByIdForUser(userId: number, collaboratorId: number) {
468
+ const actor = await this.getActorContext(userId);
469
+ this.ensureCollaboratorAccess(actor, collaboratorId);
470
+ return this.getCollaboratorDetails(collaboratorId);
471
+ }
472
+
473
+ async getTeam(userId: number) {
474
+ const actor = await this.getActorContext(userId);
475
+ this.ensureSupervisor(actor);
476
+ if (!actor.teamCollaboratorIds.length) {
477
+ return {
478
+ teamMembers: [],
479
+ projectCount: 0,
480
+ pendingApprovals: 0,
481
+ pendingItems: {
482
+ timesheets: 0,
483
+ timeOffRequests: 0,
484
+ scheduleAdjustmentRequests: 0,
485
+ },
486
+ teamProjects: [],
487
+ pendingApprovalQueue: [],
488
+ pendingTimeOffRequests: [],
489
+ pendingScheduleAdjustmentRequests: [],
490
+ };
491
+ }
492
+
493
+ const teamFilter = this.buildIdFilter(actor.teamCollaboratorIds, 'c.id', false);
494
+ const teamMembers = await this.queryRows(
495
+ `SELECT c.id,
496
+ c.user_id AS "userId",
497
+ c.code,
498
+ c.display_name AS "displayName",
499
+ c.collaborator_type AS "collaboratorType",
500
+ c.department,
501
+ c.title,
502
+ c.status,
503
+ COUNT(DISTINCT pa.id)::int AS "activeAssignments",
504
+ COUNT(DISTINCT a.id) FILTER (WHERE a.status = 'pending')::int AS "pendingApprovals",
505
+ COUNT(DISTINCT tor.id) FILTER (WHERE tor.status = 'submitted')::int AS "pendingTimeOffRequests",
506
+ COUNT(DISTINCT sar.id) FILTER (WHERE sar.status = 'submitted')::int AS "pendingScheduleAdjustmentRequests"
507
+ FROM operations_collaborator c
508
+ LEFT JOIN operations_project_assignment pa
509
+ ON pa.collaborator_id = c.id
510
+ AND pa.deleted_at IS NULL
511
+ AND pa.status IN ('planned', 'active')
512
+ LEFT JOIN operations_approval a
513
+ ON a.requester_collaborator_id = c.id
514
+ AND a.deleted_at IS NULL
515
+ AND a.status = 'pending'
516
+ LEFT JOIN operations_time_off_request tor
517
+ ON tor.collaborator_id = c.id
518
+ AND tor.deleted_at IS NULL
519
+ AND tor.status = 'submitted'
520
+ LEFT JOIN operations_schedule_adjustment_request sar
521
+ ON sar.collaborator_id = c.id
522
+ AND sar.deleted_at IS NULL
523
+ AND sar.status = 'submitted'
524
+ WHERE c.deleted_at IS NULL AND ${teamFilter.clause}
525
+ GROUP BY c.id
526
+ ORDER BY c.display_name ASC`,
527
+ teamFilter.params
528
+ );
529
+
530
+ const [teamProjects, pendingApprovalQueue, pendingTimeOffRequests, pendingScheduleAdjustmentRequests] =
531
+ await Promise.all([
532
+ this.queryRows(
533
+ `SELECT p.id,
534
+ p.code,
535
+ p.name,
536
+ p.client_name AS "clientName",
537
+ p.status,
538
+ COUNT(DISTINCT pa.collaborator_id)::int AS "teamSize",
539
+ COUNT(DISTINCT t.id) FILTER (WHERE t.status = 'submitted')::int AS "pendingTimesheets"
540
+ FROM operations_project p
541
+ JOIN operations_project_assignment pa
542
+ ON pa.project_id = p.id
543
+ AND pa.deleted_at IS NULL
544
+ AND pa.status IN ('planned', 'active')
545
+ LEFT JOIN operations_timesheet_entry te
546
+ ON te.project_assignment_id = pa.id
547
+ AND te.deleted_at IS NULL
548
+ LEFT JOIN operations_timesheet t
549
+ ON t.id = te.timesheet_id
550
+ AND t.deleted_at IS NULL
551
+ WHERE p.deleted_at IS NULL
552
+ AND pa.collaborator_id = ANY($1::int[])
553
+ GROUP BY p.id
554
+ ORDER BY p.name ASC`,
555
+ [actor.teamCollaboratorIds]
556
+ ),
557
+ this.queryRows(
558
+ `SELECT a.id,
559
+ a.target_type AS "targetType",
560
+ a.target_id AS "targetId",
561
+ a.requester_collaborator_id AS "requesterCollaboratorId",
562
+ requester.display_name AS "requesterName",
563
+ a.approver_collaborator_id AS "approverCollaboratorId",
564
+ approver.display_name AS "approverName",
565
+ a.status,
566
+ a.submitted_at AS "submittedAt",
567
+ a.decided_at AS "decidedAt",
568
+ a.decision_note AS "decisionNote",
569
+ t.week_start_date AS "timesheetWeekStartDate",
570
+ t.week_end_date AS "timesheetWeekEndDate",
571
+ t.total_hours AS "timesheetTotalHours",
572
+ COALESCE(
573
+ STRING_AGG(DISTINCT p.name, ', ') FILTER (WHERE p.name IS NOT NULL),
574
+ ''
575
+ ) AS "timesheetProjectNames",
576
+ tor.request_type AS "timeOffType",
577
+ tor.start_date AS "timeOffStartDate",
578
+ tor.end_date AS "timeOffEndDate",
579
+ tor.reason AS "timeOffReason",
580
+ sar.request_scope AS "scheduleRequestScope",
581
+ sar.effective_start_date AS "scheduleStartDate",
582
+ sar.effective_end_date AS "scheduleEndDate",
583
+ sar.reason AS "scheduleReason"
584
+ FROM operations_approval a
585
+ JOIN operations_collaborator requester
586
+ ON requester.id = a.requester_collaborator_id
587
+ LEFT JOIN operations_collaborator approver
588
+ ON approver.id = a.approver_collaborator_id
589
+ LEFT JOIN operations_timesheet t
590
+ ON a.target_type = 'timesheet'
591
+ AND t.id = a.target_id
592
+ LEFT JOIN operations_timesheet_entry te
593
+ ON te.timesheet_id = t.id
594
+ AND te.deleted_at IS NULL
595
+ LEFT JOIN operations_project_assignment pa
596
+ ON pa.id = te.project_assignment_id
597
+ LEFT JOIN operations_project p
598
+ ON p.id = pa.project_id
599
+ LEFT JOIN operations_time_off_request tor
600
+ ON a.target_type = 'time_off_request'
601
+ AND tor.id = a.target_id
602
+ LEFT JOIN operations_schedule_adjustment_request sar
603
+ ON a.target_type = 'schedule_adjustment_request'
604
+ AND sar.id = a.target_id
605
+ WHERE a.deleted_at IS NULL
606
+ AND a.status = 'pending'
607
+ AND a.approver_collaborator_id = $1
608
+ GROUP BY a.id, requester.id, approver.id, t.id, tor.id, sar.id
609
+ ORDER BY a.submitted_at DESC, a.id DESC`,
610
+ [actor.collaboratorId]
611
+ ),
612
+ this.queryRows(
613
+ `SELECT tor.id,
614
+ tor.collaborator_id AS "collaboratorId",
615
+ c.display_name AS "collaboratorName",
616
+ tor.request_type AS "requestType",
617
+ tor.start_date AS "startDate",
618
+ tor.end_date AS "endDate",
619
+ tor.total_days AS "totalDays",
620
+ tor.status,
621
+ tor.reason,
622
+ tor.submitted_at AS "submittedAt",
623
+ tor.reviewed_at AS "reviewedAt",
624
+ approval.decision_note AS "approverNote"
625
+ FROM operations_time_off_request tor
626
+ JOIN operations_collaborator c ON c.id = tor.collaborator_id
627
+ LEFT JOIN operations_approval approval
628
+ ON approval.target_type = 'time_off_request'
629
+ AND approval.target_id = tor.id
630
+ AND approval.deleted_at IS NULL
631
+ WHERE tor.deleted_at IS NULL
632
+ AND tor.status = 'submitted'
633
+ AND tor.collaborator_id = ANY($1::int[])
634
+ ORDER BY tor.start_date ASC, tor.id ASC`,
635
+ [actor.teamCollaboratorIds]
636
+ ),
637
+ this.queryRows(
638
+ `SELECT sar.id,
639
+ sar.collaborator_id AS "collaboratorId",
640
+ c.display_name AS "collaboratorName",
641
+ sar.request_scope AS "requestScope",
642
+ sar.effective_start_date AS "effectiveStartDate",
643
+ sar.effective_end_date AS "effectiveEndDate",
644
+ sar.status,
645
+ sar.reason,
646
+ sar.submitted_at AS "submittedAt",
647
+ sar.reviewed_at AS "reviewedAt",
648
+ approval.decision_note AS "approverNote"
649
+ FROM operations_schedule_adjustment_request sar
650
+ JOIN operations_collaborator c ON c.id = sar.collaborator_id
651
+ LEFT JOIN operations_approval approval
652
+ ON approval.target_type = 'schedule_adjustment_request'
653
+ AND approval.target_id = sar.id
654
+ AND approval.deleted_at IS NULL
655
+ WHERE sar.deleted_at IS NULL
656
+ AND sar.status = 'submitted'
657
+ AND sar.collaborator_id = ANY($1::int[])
658
+ ORDER BY sar.effective_start_date ASC, sar.id ASC`,
659
+ [actor.teamCollaboratorIds]
660
+ ),
661
+ ]);
662
+
663
+ const pendingItemCounts = {
664
+ timesheets: pendingApprovalQueue.filter((item) => item.targetType === 'timesheet')
665
+ .length,
666
+ timeOffRequests: pendingTimeOffRequests.length,
667
+ scheduleAdjustmentRequests: pendingScheduleAdjustmentRequests.length,
668
+ };
669
+
670
+ return {
671
+ teamMembers,
672
+ projectCount: (await this.getAssignedProjectIds(actor.teamCollaboratorIds))
673
+ .length,
674
+ pendingApprovals: Number(
675
+ (
676
+ await this.querySingle<{ total: string }>(
677
+ `SELECT COUNT(*)::text AS total
678
+ FROM operations_approval
679
+ WHERE deleted_at IS NULL
680
+ AND status = 'pending'
681
+ AND approver_collaborator_id = $1`,
682
+ [actor.collaboratorId]
683
+ )
684
+ )?.total ?? 0
685
+ ),
686
+ pendingItems: pendingItemCounts,
687
+ teamProjects,
688
+ pendingApprovalQueue,
689
+ pendingTimeOffRequests,
690
+ pendingScheduleAdjustmentRequests,
691
+ };
692
+ }
693
+
694
+ async createCollaborator(userId: number, data: CollaboratorPayload) {
695
+ const actor = await this.getActorContext(userId);
696
+ this.ensureDirector(actor);
697
+ this.requireFields(data, ['userId', 'code', 'displayName']);
698
+
699
+ const collaboratorId = await this.prisma.$transaction(async (tx) => {
700
+ const created = (await (tx as any).$queryRawUnsafe(
701
+ `INSERT INTO operations_collaborator (
702
+ user_id,
703
+ supervisor_collaborator_id,
704
+ code,
705
+ collaborator_type,
706
+ display_name,
707
+ department,
708
+ title,
709
+ level_label,
710
+ weekly_capacity_hours,
711
+ status,
712
+ joined_at,
713
+ left_at,
714
+ notes,
715
+ created_at,
716
+ updated_at
717
+ ) VALUES (
718
+ $1, $2, $3, COALESCE($4, 'other'), $5, $6, $7, $8, $9,
719
+ COALESCE($10, 'active'), $11, $12, $13, NOW(), NOW()
720
+ )
721
+ RETURNING id`,
722
+ data.userId,
723
+ data.supervisorCollaboratorId ?? null,
724
+ data.code,
725
+ data.collaboratorType ?? 'other',
726
+ data.displayName,
727
+ data.department ?? null,
728
+ data.title ?? null,
729
+ data.levelLabel ?? null,
730
+ data.weeklyCapacityHours ?? null,
731
+ data.status ?? 'active',
732
+ data.joinedAt ?? null,
733
+ data.leftAt ?? null,
734
+ data.notes ?? null
735
+ )) as { id: number }[];
736
+
737
+ const createdCollaboratorId = created[0]?.id;
738
+ await this.replaceCollaboratorScheduleDays(
739
+ tx as any,
740
+ createdCollaboratorId,
741
+ data.weeklySchedule
742
+ );
743
+
744
+ if (data.autoGenerateContractDraft !== false) {
745
+ await this.createHiringContractDraft(tx as any, actor.userId, {
746
+ collaboratorId: createdCollaboratorId,
747
+ collaboratorCode: data.code,
748
+ displayName: data.displayName,
749
+ collaboratorType: data.collaboratorType ?? 'other',
750
+ supervisorCollaboratorId: data.supervisorCollaboratorId ?? null,
751
+ startDate: data.joinedAt ?? null,
752
+ weeklyCapacityHours: data.weeklyCapacityHours ?? null,
753
+ compensationAmount: data.compensationAmount ?? null,
754
+ description: data.contractDescription ?? data.notes ?? null,
755
+ });
756
+ }
757
+
758
+ return createdCollaboratorId;
759
+ });
760
+
761
+ return this.getCollaboratorByIdForUser(userId, collaboratorId);
762
+ }
763
+
764
+ async updateCollaborator(
765
+ userId: number,
766
+ collaboratorId: number,
767
+ data: Partial<CollaboratorPayload>
768
+ ) {
769
+ const actor = await this.getActorContext(userId);
770
+ this.ensureDirector(actor);
771
+ await this.getCollaboratorById(collaboratorId);
772
+
773
+ const updates: string[] = [];
774
+ const params: unknown[] = [];
775
+ this.pushUpdate(updates, params, 'supervisor_collaborator_id', data.supervisorCollaboratorId);
776
+ this.pushUpdate(updates, params, 'code', data.code);
777
+ this.pushUpdate(updates, params, 'collaborator_type', data.collaboratorType);
778
+ this.pushUpdate(updates, params, 'display_name', data.displayName);
779
+ this.pushUpdate(updates, params, 'department', data.department);
780
+ this.pushUpdate(updates, params, 'title', data.title);
781
+ this.pushUpdate(updates, params, 'level_label', data.levelLabel);
782
+ this.pushUpdate(updates, params, 'weekly_capacity_hours', data.weeklyCapacityHours);
783
+ this.pushUpdate(updates, params, 'status', data.status);
784
+ this.pushUpdate(updates, params, 'joined_at', data.joinedAt);
785
+ this.pushUpdate(updates, params, 'left_at', data.leftAt);
786
+ this.pushUpdate(updates, params, 'notes', data.notes);
787
+
788
+ await this.prisma.$transaction(async (tx) => {
789
+ if (updates.length) {
790
+ params.push(collaboratorId);
791
+ await (tx as any).$executeRawUnsafe(
792
+ `UPDATE operations_collaborator
793
+ SET ${updates.join(', ')},
794
+ updated_at = NOW()
795
+ WHERE id = $${params.length}`,
796
+ ...params
797
+ );
798
+ }
799
+
800
+ if (data.weeklySchedule) {
801
+ await this.replaceCollaboratorScheduleDays(
802
+ tx as any,
803
+ collaboratorId,
804
+ data.weeklySchedule
805
+ );
806
+ }
807
+ });
808
+
809
+ return this.getCollaboratorByIdForUser(userId, collaboratorId);
810
+ }
811
+
812
+ async listProjects(userId: number) {
813
+ const actor = await this.getActorContext(userId);
814
+ const filter = this.buildIdFilter(actor.visibleProjectIds, 'p.id', actor.isDirector);
815
+ const assignmentParams: unknown[] = [];
816
+ const ownAssignmentSelect = actor.collaboratorId
817
+ ? `MAX(CASE WHEN pa.collaborator_id = ${this.param(
818
+ assignmentParams,
819
+ actor.collaboratorId
820
+ )} THEN pa.id END)::int AS "myAssignmentId",
821
+ MAX(CASE WHEN pa.collaborator_id = ${this.param(
822
+ assignmentParams,
823
+ actor.collaboratorId
824
+ )} THEN pa.role_label END) AS "myRoleLabel",`
825
+ : `NULL::int AS "myAssignmentId",
826
+ NULL::varchar AS "myRoleLabel",`;
827
+
828
+ return this.queryRows(
829
+ `SELECT p.id,
830
+ p.contract_id AS "contractId",
831
+ p.manager_collaborator_id AS "managerCollaboratorId",
832
+ p.code,
833
+ p.name,
834
+ p.client_name AS "clientName",
835
+ p.summary,
836
+ p.status,
837
+ p.progress_percent AS "progressPercent",
838
+ p.delivery_model AS "deliveryModel",
839
+ p.budget_amount AS "budgetAmount",
840
+ p.start_date AS "startDate",
841
+ p.end_date AS "endDate",
842
+ c.name AS "contractName",
843
+ c.status AS "contractStatus",
844
+ m.display_name AS "managerName",
845
+ ${ownAssignmentSelect}
846
+ COUNT(DISTINCT pa.id)::int AS "teamSize"
847
+ FROM operations_project p
848
+ LEFT JOIN operations_contract c ON c.id = p.contract_id
849
+ LEFT JOIN operations_collaborator m ON m.id = p.manager_collaborator_id
850
+ LEFT JOIN operations_project_assignment pa
851
+ ON pa.project_id = p.id
852
+ AND pa.deleted_at IS NULL
853
+ AND pa.status IN ('planned', 'active')
854
+ WHERE p.deleted_at IS NULL AND ${filter.clause}
855
+ GROUP BY p.id, c.id, m.id
856
+ ORDER BY p.name ASC`,
857
+ [...assignmentParams, ...filter.params]
858
+ );
859
+ }
860
+
861
+ async getProjectById(userId: number, projectId: number) {
862
+ const actor = await this.getActorContext(userId);
863
+ await this.assertProjectAccess(actor, projectId);
864
+ return this.getProjectDetails(projectId, actor.collaboratorId);
865
+ }
866
+
867
+ async createProject(userId: number, data: ProjectPayload) {
868
+ const actor = await this.getActorContext(userId);
869
+ this.ensureDirector(actor);
870
+ this.requireFields(data, ['code', 'name']);
871
+
872
+ const createdProjectId = await this.prisma.$transaction(async (tx) => {
873
+ const created = await (tx as any).$queryRawUnsafe(
874
+ `INSERT INTO operations_project (
875
+ contract_id,
876
+ manager_collaborator_id,
877
+ code,
878
+ name,
879
+ client_name,
880
+ summary,
881
+ status,
882
+ progress_percent,
883
+ delivery_model,
884
+ budget_amount,
885
+ start_date,
886
+ end_date,
887
+ created_at,
888
+ updated_at
889
+ ) VALUES (
890
+ $1, $2, $3, $4, $5, $6, COALESCE($7, 'planning'), $8,
891
+ COALESCE($9, 'project_delivery'), $10, $11, $12, NOW(), NOW()
892
+ )
893
+ RETURNING id`,
894
+ data.contractId ?? null,
895
+ data.managerCollaboratorId ?? null,
896
+ data.code,
897
+ data.name,
898
+ data.clientName ?? null,
899
+ data.summary ?? null,
900
+ data.status ?? 'planning',
901
+ data.progressPercent ?? null,
902
+ data.deliveryModel ?? 'project_delivery',
903
+ data.budgetAmount ?? null,
904
+ data.startDate ?? null,
905
+ data.endDate ?? null
906
+ );
907
+
908
+ const projectId = (created as { id: number }[])[0]?.id;
909
+
910
+ if (data.teamAssignments?.length) {
911
+ await this.replaceProjectAssignments(
912
+ tx as any,
913
+ projectId,
914
+ data.teamAssignments
915
+ );
916
+ }
917
+
918
+ if (!data.contractId && data.autoGenerateContractDraft !== false) {
919
+ const contractId = await this.createProjectContractDraft(
920
+ tx as any,
921
+ actor.userId,
922
+ {
923
+ projectId,
924
+ projectCode: data.code,
925
+ projectName: data.name,
926
+ clientName: data.clientName ?? data.name,
927
+ managerCollaboratorId: data.managerCollaboratorId ?? null,
928
+ startDate: data.startDate ?? null,
929
+ endDate: data.endDate ?? null,
930
+ budgetAmount: data.budgetAmount ?? null,
931
+ monthlyHourCap: data.monthlyHourCap ?? null,
932
+ billingModel: data.billingModel ?? 'time_and_material',
933
+ contractCode: data.contractCode ?? null,
934
+ contractName: data.contractName ?? null,
935
+ description: data.contractDescription ?? data.summary ?? null,
936
+ }
937
+ );
938
+
939
+ await (tx as any).$executeRawUnsafe(
940
+ `UPDATE operations_project
941
+ SET contract_id = $1,
942
+ updated_at = NOW()
943
+ WHERE id = $2`,
944
+ contractId,
945
+ projectId
946
+ );
947
+ }
948
+
949
+ return projectId;
950
+ });
951
+
952
+ return this.getProjectById(userId, createdProjectId);
953
+ }
954
+
955
+ async updateProject(userId: number, projectId: number, data: Partial<ProjectPayload>) {
956
+ const actor = await this.getActorContext(userId);
957
+ this.ensureDirector(actor);
958
+ await this.getProjectById(userId, projectId);
959
+
960
+ const updates: string[] = [];
961
+ const params: unknown[] = [];
962
+ this.pushUpdate(updates, params, 'contract_id', data.contractId);
963
+ this.pushUpdate(updates, params, 'manager_collaborator_id', data.managerCollaboratorId);
964
+ this.pushUpdate(updates, params, 'code', data.code);
965
+ this.pushUpdate(updates, params, 'name', data.name);
966
+ this.pushUpdate(updates, params, 'client_name', data.clientName);
967
+ this.pushUpdate(updates, params, 'summary', data.summary);
968
+ this.pushUpdate(updates, params, 'status', data.status);
969
+ this.pushUpdate(updates, params, 'progress_percent', data.progressPercent);
970
+ this.pushUpdate(updates, params, 'delivery_model', data.deliveryModel);
971
+ this.pushUpdate(updates, params, 'budget_amount', data.budgetAmount);
972
+ this.pushUpdate(updates, params, 'start_date', data.startDate);
973
+ this.pushUpdate(updates, params, 'end_date', data.endDate);
974
+
975
+ await this.prisma.$transaction(async (tx) => {
976
+ if (updates.length) {
977
+ params.push(projectId);
978
+ await (tx as any).$executeRawUnsafe(
979
+ `UPDATE operations_project
980
+ SET ${updates.join(', ')},
981
+ updated_at = NOW()
982
+ WHERE id = $${params.length}`,
983
+ ...params
984
+ );
985
+ }
986
+
987
+ if (data.teamAssignments) {
988
+ await this.replaceProjectAssignments(
989
+ tx as any,
990
+ projectId,
991
+ data.teamAssignments
992
+ );
993
+ }
994
+ });
995
+
996
+ return this.getProjectById(userId, projectId);
997
+ }
998
+
999
+ async listContracts(userId: number) {
1000
+ const actor = await this.getActorContext(userId);
1001
+ const params: unknown[] = [];
1002
+ const accessClause = actor.isDirector
1003
+ ? 'c.deleted_at IS NULL'
1004
+ : `c.deleted_at IS NULL AND (
1005
+ c.related_collaborator_id = ANY(${this.param(params, actor.visibleCollaboratorIds)}::int[])
1006
+ OR EXISTS (
1007
+ SELECT 1
1008
+ FROM operations_project p_access
1009
+ WHERE p_access.contract_id = c.id
1010
+ AND p_access.deleted_at IS NULL
1011
+ AND p_access.id = ANY(${this.param(params, actor.visibleProjectIds)}::int[])
1012
+ )
1013
+ )`;
1014
+
1015
+ return this.queryRows(
1016
+ `SELECT c.id,
1017
+ c.code,
1018
+ c.name,
1019
+ c.contract_category AS "contractCategory",
1020
+ c.contract_type AS "contractType",
1021
+ c.client_name AS "clientName",
1022
+ c.signature_status AS "signatureStatus",
1023
+ c.is_active AS "isActive",
1024
+ c.billing_model AS "billingModel",
1025
+ c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
1026
+ c.related_collaborator_id AS "relatedCollaboratorId",
1027
+ c.origin_type AS "originType",
1028
+ c.origin_id AS "originId",
1029
+ c.start_date AS "startDate",
1030
+ c.end_date AS "endDate",
1031
+ c.signed_at AS "signedAt",
1032
+ c.effective_date AS "effectiveDate",
1033
+ c.budget_amount AS "budgetAmount",
1034
+ c.monthly_hour_cap AS "monthlyHourCap",
1035
+ c.status,
1036
+ c.description,
1037
+ m.display_name AS "accountManagerName",
1038
+ linked.display_name AS "relatedCollaboratorName",
1039
+ COALESCE(primary_party.display_name, linked.display_name, c.client_name) AS "mainRelatedPartyName",
1040
+ COALESCE(financials.value_amount, 0) AS "valueAmount",
1041
+ COALESCE(financials.payment_amount, 0) AS "paymentAmount",
1042
+ COALESCE(financials.revenue_amount, 0) AS "revenueAmount",
1043
+ COALESCE(financials.fine_amount, 0) AS "fineAmount",
1044
+ COALESCE(pdf_document.file_name, '') AS "currentPdfFileName",
1045
+ COUNT(DISTINCT p.id)::int AS "projectCount"
1046
+ FROM operations_contract c
1047
+ LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
1048
+ LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
1049
+ LEFT JOIN LATERAL (
1050
+ SELECT cp.display_name
1051
+ FROM operations_contract_party cp
1052
+ WHERE cp.contract_id = c.id
1053
+ AND cp.deleted_at IS NULL
1054
+ ORDER BY cp.is_primary DESC, cp.id ASC
1055
+ LIMIT 1
1056
+ ) primary_party ON TRUE
1057
+ LEFT JOIN LATERAL (
1058
+ SELECT
1059
+ SUM(CASE WHEN term_type = 'value' THEN amount ELSE 0 END) AS value_amount,
1060
+ SUM(CASE WHEN term_type = 'payment' THEN amount ELSE 0 END) AS payment_amount,
1061
+ SUM(CASE WHEN term_type = 'revenue' THEN amount ELSE 0 END) AS revenue_amount,
1062
+ SUM(CASE WHEN term_type = 'fine' THEN amount ELSE 0 END) AS fine_amount
1063
+ FROM operations_contract_financial_term ft
1064
+ WHERE ft.contract_id = c.id
1065
+ AND ft.deleted_at IS NULL
1066
+ ) financials ON TRUE
1067
+ LEFT JOIN LATERAL (
1068
+ SELECT cd.file_name
1069
+ FROM operations_contract_document cd
1070
+ WHERE cd.contract_id = c.id
1071
+ AND cd.deleted_at IS NULL
1072
+ AND cd.is_current = true
1073
+ AND cd.document_type IN ('uploaded_pdf', 'generated_pdf')
1074
+ ORDER BY cd.id DESC
1075
+ LIMIT 1
1076
+ ) pdf_document ON TRUE
1077
+ LEFT JOIN operations_project p
1078
+ ON p.contract_id = c.id
1079
+ AND p.deleted_at IS NULL
1080
+ WHERE ${accessClause}
1081
+ GROUP BY c.id, m.id, linked.id
1082
+ ORDER BY c.name ASC`,
1083
+ params
1084
+ );
1085
+ }
1086
+
1087
+ async getContractById(userId: number, contractId: number) {
1088
+ const actor = await this.getActorContext(userId);
1089
+ const contract = await this.querySingle(
1090
+ `SELECT c.id,
1091
+ c.code,
1092
+ c.name,
1093
+ c.contract_category AS "contractCategory",
1094
+ c.contract_type AS "contractType",
1095
+ c.client_name AS "clientName",
1096
+ c.signature_status AS "signatureStatus",
1097
+ c.is_active AS "isActive",
1098
+ c.billing_model AS "billingModel",
1099
+ c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
1100
+ c.related_collaborator_id AS "relatedCollaboratorId",
1101
+ c.origin_type AS "originType",
1102
+ c.origin_id AS "originId",
1103
+ c.start_date AS "startDate",
1104
+ c.end_date AS "endDate",
1105
+ c.signed_at AS "signedAt",
1106
+ c.effective_date AS "effectiveDate",
1107
+ c.budget_amount AS "budgetAmount",
1108
+ c.monthly_hour_cap AS "monthlyHourCap",
1109
+ c.status,
1110
+ c.description,
1111
+ c.content_html AS "contentHtml",
1112
+ m.display_name AS "accountManagerName",
1113
+ linked.display_name AS "relatedCollaboratorName"
1114
+ FROM operations_contract c
1115
+ LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
1116
+ LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
1117
+ WHERE c.id = $1
1118
+ AND c.deleted_at IS NULL`,
1119
+ [contractId]
1120
+ );
1121
+ if (!contract) {
1122
+ throw new NotFoundException('Contract not found.');
1123
+ }
1124
+
1125
+ if (!actor.isDirector) {
1126
+ const access = await this.querySingle<{ exists: boolean }>(
1127
+ `SELECT EXISTS (
1128
+ SELECT 1
1129
+ FROM operations_contract c
1130
+ WHERE c.id = $1
1131
+ AND c.deleted_at IS NULL
1132
+ AND (
1133
+ c.related_collaborator_id = ANY($2::int[])
1134
+ OR EXISTS (
1135
+ SELECT 1
1136
+ FROM operations_project p
1137
+ WHERE p.contract_id = c.id
1138
+ AND p.deleted_at IS NULL
1139
+ AND p.id = ANY($3::int[])
1140
+ )
1141
+ )
1142
+ ) AS exists`,
1143
+ [contractId, actor.visibleCollaboratorIds, actor.visibleProjectIds]
1144
+ );
1145
+ if (!access?.exists) {
1146
+ throw new ForbiddenException('You do not have access to this contract.');
1147
+ }
1148
+ }
1149
+
1150
+ const [projects, scheduleSummary, parties, signatures, financialTerms, documents, revisions, history] =
1151
+ await Promise.all([
1152
+ this.queryRows(
1153
+ `SELECT id, code, name, status
1154
+ FROM operations_project
1155
+ WHERE contract_id = $1
1156
+ AND deleted_at IS NULL
1157
+ ORDER BY name ASC`,
1158
+ [contractId]
1159
+ ),
1160
+ contract.relatedCollaboratorId
1161
+ ? this.queryRows(
1162
+ `SELECT weekday,
1163
+ is_working_day AS "isWorkingDay",
1164
+ start_time AS "startTime",
1165
+ end_time AS "endTime",
1166
+ break_minutes AS "breakMinutes"
1167
+ FROM operations_collaborator_schedule_day
1168
+ WHERE collaborator_id = $1
1169
+ AND deleted_at IS NULL
1170
+ ORDER BY id ASC`,
1171
+ [contract.relatedCollaboratorId]
1172
+ )
1173
+ : Promise.resolve([]),
1174
+ this.queryRows(
1175
+ `SELECT id,
1176
+ party_role AS "partyRole",
1177
+ party_type AS "partyType",
1178
+ display_name AS "displayName",
1179
+ document_number AS "documentNumber",
1180
+ email,
1181
+ phone,
1182
+ is_primary AS "isPrimary"
1183
+ FROM operations_contract_party
1184
+ WHERE contract_id = $1
1185
+ AND deleted_at IS NULL
1186
+ ORDER BY is_primary DESC, id ASC`,
1187
+ [contractId]
1188
+ ),
1189
+ this.queryRows(
1190
+ `SELECT id,
1191
+ signer_name AS "signerName",
1192
+ signer_role AS "signerRole",
1193
+ signer_email AS "signerEmail",
1194
+ signer_status AS status,
1195
+ signed_at AS "signedAt"
1196
+ FROM operations_contract_signature
1197
+ WHERE contract_id = $1
1198
+ AND deleted_at IS NULL
1199
+ ORDER BY id ASC`,
1200
+ [contractId]
1201
+ ),
1202
+ this.queryRows(
1203
+ `SELECT id,
1204
+ term_type AS "termType",
1205
+ label,
1206
+ amount,
1207
+ recurrence,
1208
+ due_day AS "dueDay",
1209
+ notes
1210
+ FROM operations_contract_financial_term
1211
+ WHERE contract_id = $1
1212
+ AND deleted_at IS NULL
1213
+ ORDER BY id ASC`,
1214
+ [contractId]
1215
+ ),
1216
+ this.queryRows(
1217
+ `SELECT id,
1218
+ document_type AS "documentType",
1219
+ file_name AS "fileName",
1220
+ mime_type AS "mimeType",
1221
+ file_content_base64 AS "fileContentBase64",
1222
+ is_current AS "isCurrent",
1223
+ notes,
1224
+ created_at AS "createdAt"
1225
+ FROM operations_contract_document
1226
+ WHERE contract_id = $1
1227
+ AND deleted_at IS NULL
1228
+ ORDER BY is_current DESC, id DESC`,
1229
+ [contractId]
1230
+ ),
1231
+ this.queryRows(
1232
+ `SELECT id,
1233
+ revision_type AS "revisionType",
1234
+ title,
1235
+ effective_date AS "effectiveDate",
1236
+ status,
1237
+ summary
1238
+ FROM operations_contract_revision
1239
+ WHERE contract_id = $1
1240
+ AND deleted_at IS NULL
1241
+ ORDER BY effective_date DESC NULLS LAST, id DESC`,
1242
+ [contractId]
1243
+ ),
1244
+ this.queryRows(
1245
+ `SELECT id,
1246
+ actor_user_id AS "actorUserId",
1247
+ action,
1248
+ note,
1249
+ metadata_json AS "metadataJson",
1250
+ created_at AS "createdAt"
1251
+ FROM operations_contract_history
1252
+ WHERE contract_id = $1
1253
+ ORDER BY id DESC`,
1254
+ [contractId]
1255
+ ),
1256
+ ]);
1257
+
1258
+ return {
1259
+ ...contract,
1260
+ mainRelatedPartyName:
1261
+ parties.find((party: any) => party.isPrimary)?.displayName ??
1262
+ contract.relatedCollaboratorName ??
1263
+ contract.clientName,
1264
+ projects,
1265
+ scheduleSummary,
1266
+ parties,
1267
+ signatures,
1268
+ financialTerms,
1269
+ documents,
1270
+ revisions,
1271
+ history,
1272
+ };
1273
+ }
1274
+
1275
+ async createContract(userId: number, data: ContractPayload) {
1276
+ const actor = await this.getActorContext(userId);
1277
+ this.ensureDirector(actor);
1278
+ this.requireFields(data, ['code', 'name', 'clientName', 'startDate']);
1279
+
1280
+ const createdId = await this.prisma.$transaction(async (tx) => {
1281
+ const created = await (tx as any).$queryRawUnsafe(
1282
+ `INSERT INTO operations_contract (
1283
+ code,
1284
+ name,
1285
+ contract_category,
1286
+ contract_type,
1287
+ client_name,
1288
+ signature_status,
1289
+ is_active,
1290
+ billing_model,
1291
+ account_manager_collaborator_id,
1292
+ related_collaborator_id,
1293
+ origin_type,
1294
+ origin_id,
1295
+ start_date,
1296
+ end_date,
1297
+ signed_at,
1298
+ effective_date,
1299
+ budget_amount,
1300
+ monthly_hour_cap,
1301
+ status,
1302
+ description,
1303
+ content_html,
1304
+ created_at,
1305
+ updated_at
1306
+ ) VALUES (
1307
+ $1, $2, COALESCE($3, 'client'), COALESCE($4, 'service_agreement'), $5, COALESCE($6, 'not_started'),
1308
+ COALESCE($7, true), COALESCE($8, 'time_and_material'), $9, $10, COALESCE($11, 'manual'), $12, $13,
1309
+ $14, $15, $16, $17, $18, COALESCE($19, 'draft'), $20, $21, NOW(), NOW()
1310
+ )
1311
+ RETURNING id`,
1312
+ data.code,
1313
+ data.name,
1314
+ data.contractCategory ?? 'client',
1315
+ data.contractType ?? 'service_agreement',
1316
+ data.clientName,
1317
+ data.signatureStatus ?? 'not_started',
1318
+ data.isActive ?? true,
1319
+ data.billingModel ?? 'time_and_material',
1320
+ data.accountManagerCollaboratorId ?? null,
1321
+ data.relatedCollaboratorId ?? null,
1322
+ data.originType ?? 'manual',
1323
+ data.originId ?? null,
1324
+ data.startDate,
1325
+ data.endDate ?? null,
1326
+ data.signedAt ?? null,
1327
+ data.effectiveDate ?? data.startDate,
1328
+ data.budgetAmount ?? null,
1329
+ data.monthlyHourCap ?? null,
1330
+ data.status ?? 'draft',
1331
+ data.description ?? null,
1332
+ data.contentHtml ?? null
1333
+ );
1334
+
1335
+ const contractId = (created as { id: number }[])[0]?.id;
1336
+ await this.replaceContractParties(tx as any, contractId, data.parties);
1337
+ await this.replaceContractSignatures(tx as any, contractId, data.signatures);
1338
+ await this.replaceContractFinancialTerms(
1339
+ tx as any,
1340
+ contractId,
1341
+ data.financialTerms
1342
+ );
1343
+ await this.replaceContractRevisions(tx as any, contractId, data.revisions);
1344
+ if (data.replaceUploadedPdfDocument) {
1345
+ await this.replaceContractPdfDocument(
1346
+ tx as any,
1347
+ contractId,
1348
+ data.replaceUploadedPdfDocument
1349
+ );
1350
+ }
1351
+ await this.insertContractHistory(
1352
+ tx as any,
1353
+ contractId,
1354
+ userId,
1355
+ 'created',
1356
+ data.originType === 'manual'
1357
+ ? 'Manual contract created from registry.'
1358
+ : `Contract registered from origin ${data.originType}.`
1359
+ );
1360
+ return contractId;
1361
+ });
1362
+
1363
+ return this.getContractById(userId, createdId);
1364
+ }
1365
+
1366
+ async updateContract(userId: number, contractId: number, data: Partial<ContractPayload>) {
1367
+ const actor = await this.getActorContext(userId);
1368
+ this.ensureDirector(actor);
1369
+ await this.getContractById(userId, contractId);
1370
+
1371
+ const updates: string[] = [];
1372
+ const params: unknown[] = [];
1373
+ this.pushUpdate(updates, params, 'code', data.code);
1374
+ this.pushUpdate(updates, params, 'name', data.name);
1375
+ this.pushUpdate(updates, params, 'contract_category', data.contractCategory);
1376
+ this.pushUpdate(updates, params, 'contract_type', data.contractType);
1377
+ this.pushUpdate(updates, params, 'client_name', data.clientName);
1378
+ this.pushUpdate(updates, params, 'signature_status', data.signatureStatus);
1379
+ this.pushUpdate(updates, params, 'is_active', data.isActive);
1380
+ this.pushUpdate(updates, params, 'billing_model', data.billingModel);
1381
+ this.pushUpdate(updates, params, 'account_manager_collaborator_id', data.accountManagerCollaboratorId);
1382
+ this.pushUpdate(updates, params, 'related_collaborator_id', data.relatedCollaboratorId);
1383
+ this.pushUpdate(updates, params, 'origin_type', data.originType);
1384
+ this.pushUpdate(updates, params, 'origin_id', data.originId);
1385
+ this.pushUpdate(updates, params, 'start_date', data.startDate);
1386
+ this.pushUpdate(updates, params, 'end_date', data.endDate);
1387
+ this.pushUpdate(updates, params, 'signed_at', data.signedAt);
1388
+ this.pushUpdate(updates, params, 'effective_date', data.effectiveDate);
1389
+ this.pushUpdate(updates, params, 'budget_amount', data.budgetAmount);
1390
+ this.pushUpdate(updates, params, 'monthly_hour_cap', data.monthlyHourCap);
1391
+ this.pushUpdate(updates, params, 'status', data.status);
1392
+ this.pushUpdate(updates, params, 'description', data.description);
1393
+ this.pushUpdate(updates, params, 'content_html', data.contentHtml);
1394
+
1395
+ await this.prisma.$transaction(async (tx) => {
1396
+ if (updates.length) {
1397
+ params.push(contractId);
1398
+ await (tx as any).$executeRawUnsafe(
1399
+ `UPDATE operations_contract
1400
+ SET ${updates.join(', ')},
1401
+ updated_at = NOW()
1402
+ WHERE id = $${params.length}`,
1403
+ ...params
1404
+ );
1405
+ }
1406
+
1407
+ if (data.parties) {
1408
+ await this.replaceContractParties(tx as any, contractId, data.parties);
1409
+ }
1410
+ if (data.signatures) {
1411
+ await this.replaceContractSignatures(
1412
+ tx as any,
1413
+ contractId,
1414
+ data.signatures
1415
+ );
1416
+ }
1417
+ if (data.financialTerms) {
1418
+ await this.replaceContractFinancialTerms(
1419
+ tx as any,
1420
+ contractId,
1421
+ data.financialTerms
1422
+ );
1423
+ }
1424
+ if (data.revisions) {
1425
+ await this.replaceContractRevisions(
1426
+ tx as any,
1427
+ contractId,
1428
+ data.revisions
1429
+ );
1430
+ }
1431
+ if (data.replaceUploadedPdfDocument) {
1432
+ await this.replaceContractPdfDocument(
1433
+ tx as any,
1434
+ contractId,
1435
+ data.replaceUploadedPdfDocument
1436
+ );
1437
+ }
1438
+
1439
+ await this.insertContractHistory(
1440
+ tx as any,
1441
+ contractId,
1442
+ userId,
1443
+ 'updated',
1444
+ 'Contract registry data updated.'
1445
+ );
1446
+ });
1447
+
1448
+ return this.getContractById(userId, contractId);
1449
+ }
1450
+
1451
+ async listTimesheets(userId: number) {
1452
+ const actor = await this.getActorContext(userId);
1453
+ const filter = this.buildIdFilter(actor.visibleCollaboratorIds, 't.collaborator_id', actor.isDirector);
1454
+
1455
+ const headers = await this.queryRows<{
1456
+ id: number;
1457
+ collaboratorId: number;
1458
+ collaboratorName: string;
1459
+ approverCollaboratorId: number | null;
1460
+ approverName: string | null;
1461
+ weekStartDate: string;
1462
+ weekEndDate: string;
1463
+ totalHours: number | null;
1464
+ status: string;
1465
+ submittedAt: string | null;
1466
+ reviewedAt: string | null;
1467
+ notes: string | null;
1468
+ decisionNote: string | null;
1469
+ }>(
1470
+ `SELECT t.id,
1471
+ t.collaborator_id AS "collaboratorId",
1472
+ c.display_name AS "collaboratorName",
1473
+ t.approver_collaborator_id AS "approverCollaboratorId",
1474
+ a.display_name AS "approverName",
1475
+ t.week_start_date AS "weekStartDate",
1476
+ t.week_end_date AS "weekEndDate",
1477
+ t.total_hours AS "totalHours",
1478
+ t.status,
1479
+ t.submitted_at AS "submittedAt",
1480
+ t.reviewed_at AS "reviewedAt",
1481
+ t.notes,
1482
+ approval.decision_note AS "decisionNote"
1483
+ FROM operations_timesheet t
1484
+ JOIN operations_collaborator c ON c.id = t.collaborator_id
1485
+ LEFT JOIN operations_collaborator a ON a.id = t.approver_collaborator_id
1486
+ LEFT JOIN operations_approval approval
1487
+ ON approval.target_type = 'timesheet'
1488
+ AND approval.target_id = t.id
1489
+ AND approval.deleted_at IS NULL
1490
+ WHERE t.deleted_at IS NULL AND ${filter.clause}
1491
+ ORDER BY t.week_start_date DESC, t.id DESC`,
1492
+ filter.params
1493
+ );
1494
+
1495
+ if (!headers.length) {
1496
+ return headers;
1497
+ }
1498
+
1499
+ const entries = await this.queryRows<{
1500
+ id: number;
1501
+ timesheetId: number;
1502
+ projectAssignmentId: number | null;
1503
+ projectId: number | null;
1504
+ projectName: string | null;
1505
+ roleLabel: string | null;
1506
+ activityLabel: string | null;
1507
+ workDate: string;
1508
+ hours: number;
1509
+ description: string | null;
1510
+ }>(
1511
+ `SELECT e.id,
1512
+ e.timesheet_id AS "timesheetId",
1513
+ e.project_assignment_id AS "projectAssignmentId",
1514
+ pa.project_id AS "projectId",
1515
+ p.name AS "projectName",
1516
+ pa.role_label AS "roleLabel",
1517
+ e.activity_label AS "activityLabel",
1518
+ e.work_date AS "workDate",
1519
+ e.hours,
1520
+ e.description
1521
+ FROM operations_timesheet_entry e
1522
+ LEFT JOIN operations_project_assignment pa ON pa.id = e.project_assignment_id
1523
+ LEFT JOIN operations_project p ON p.id = pa.project_id
1524
+ WHERE e.deleted_at IS NULL
1525
+ AND e.timesheet_id = ANY($1::int[])
1526
+ ORDER BY e.work_date ASC, e.id ASC`,
1527
+ [headers.map((item) => item.id)]
1528
+ );
1529
+
1530
+ const grouped = this.groupBy(entries, 'timesheetId');
1531
+ return headers.map((timesheet) => ({
1532
+ ...timesheet,
1533
+ entries: grouped[timesheet.id] ?? [],
1534
+ }));
1535
+ }
1536
+
1537
+ async createTimesheet(userId: number, data: TimesheetPayload) {
1538
+ const actor = await this.getActorContext(userId);
1539
+ this.ensureCollaborator(actor);
1540
+ this.requireFields(data, ['weekStartDate', 'weekEndDate']);
1541
+
1542
+ const collaboratorId =
1543
+ actor.isDirector && data.collaboratorId
1544
+ ? data.collaboratorId
1545
+ : actor.collaboratorId;
1546
+
1547
+ if (!collaboratorId) {
1548
+ throw new BadRequestException('Collaborator context is required.');
1549
+ }
1550
+ if (!actor.isDirector && collaboratorId !== actor.collaboratorId) {
1551
+ throw new ForbiddenException('You can only create your own timesheets.');
1552
+ }
1553
+
1554
+ const collaborator = await this.getCollaboratorById(collaboratorId);
1555
+ const created = await this.prisma.$transaction(async (tx) => {
1556
+ const row = (await (tx as any).$queryRawUnsafe(
1557
+ `INSERT INTO operations_timesheet (
1558
+ collaborator_id,
1559
+ approver_collaborator_id,
1560
+ week_start_date,
1561
+ week_end_date,
1562
+ notes,
1563
+ status,
1564
+ created_at,
1565
+ updated_at
1566
+ ) VALUES ($1, $2, $3, $4, $5, 'draft', NOW(), NOW())
1567
+ RETURNING id`,
1568
+ collaboratorId,
1569
+ collaborator.supervisorId ?? null,
1570
+ data.weekStartDate,
1571
+ data.weekEndDate,
1572
+ data.notes ?? null
1573
+ )) as { id: number }[];
1574
+ const timesheetId = row[0]?.id;
1575
+ await this.replaceTimesheetEntries(
1576
+ tx as any,
1577
+ timesheetId,
1578
+ data.entries ?? [],
1579
+ collaboratorId
1580
+ );
1581
+ await this.refreshTimesheetTotal(tx as any, timesheetId);
1582
+ return timesheetId;
1583
+ });
1584
+
1585
+ return this.listSingleTimesheet(actor, created);
1586
+ }
1587
+
1588
+ async updateTimesheet(
1589
+ userId: number,
1590
+ timesheetId: number,
1591
+ data: Partial<TimesheetPayload>
1592
+ ) {
1593
+ const actor = await this.getActorContext(userId);
1594
+ const current = await this.getTimesheetById(timesheetId);
1595
+
1596
+ if (!actor.isDirector && current.collaboratorId !== actor.collaboratorId) {
1597
+ throw new ForbiddenException('You can only update your own timesheets.');
1598
+ }
1599
+ if (!actor.isDirector && !['draft', 'rejected'].includes(current.status)) {
1600
+ throw new BadRequestException('Only draft or rejected timesheets can be edited.');
1601
+ }
1602
+
1603
+ await this.prisma.$transaction(async (tx) => {
1604
+ const updates: string[] = [];
1605
+ const params: unknown[] = [];
1606
+ this.pushUpdate(updates, params, 'week_start_date', data.weekStartDate);
1607
+ this.pushUpdate(updates, params, 'week_end_date', data.weekEndDate);
1608
+ this.pushUpdate(updates, params, 'notes', data.notes);
1609
+
1610
+ if (updates.length) {
1611
+ params.push(timesheetId);
1612
+ await (tx as any).$executeRawUnsafe(
1613
+ `UPDATE operations_timesheet
1614
+ SET ${updates.join(', ')},
1615
+ updated_at = NOW()
1616
+ WHERE id = $${params.length}`,
1617
+ ...params
1618
+ );
1619
+ }
1620
+
1621
+ if (data.entries) {
1622
+ await this.replaceTimesheetEntries(
1623
+ tx as any,
1624
+ timesheetId,
1625
+ data.entries,
1626
+ current.collaboratorId
1627
+ );
1628
+ }
1629
+
1630
+ await this.refreshTimesheetTotal(tx as any, timesheetId);
1631
+ });
1632
+
1633
+ return this.listSingleTimesheet(actor, timesheetId);
1634
+ }
1635
+
1636
+ async submitTimesheet(userId: number, timesheetId: number) {
1637
+ const actor = await this.getActorContext(userId);
1638
+ const current = await this.getTimesheetById(timesheetId);
1639
+ if (!actor.isDirector && current.collaboratorId !== actor.collaboratorId) {
1640
+ throw new ForbiddenException('You can only submit your own timesheets.');
1641
+ }
1642
+ if (!actor.isDirector && !['draft', 'rejected'].includes(current.status)) {
1643
+ throw new BadRequestException('Only draft or rejected timesheets can be submitted.');
1644
+ }
1645
+
1646
+ const collaborator = await this.getCollaboratorById(current.collaboratorId);
1647
+ const approverId =
1648
+ current.approverCollaboratorId ?? collaborator.supervisorId ?? null;
1649
+
1650
+ await this.prisma.$transaction(async (tx) => {
1651
+ await (tx as any).$executeRawUnsafe(
1652
+ `UPDATE operations_timesheet
1653
+ SET status = 'submitted',
1654
+ approver_collaborator_id = $1,
1655
+ submitted_at = NOW(),
1656
+ updated_at = NOW()
1657
+ WHERE id = $2`,
1658
+ approverId,
1659
+ timesheetId
1660
+ );
1661
+ await this.upsertApproval(tx as any, {
1662
+ targetType: 'timesheet',
1663
+ targetId: timesheetId,
1664
+ requesterCollaboratorId: current.collaboratorId,
1665
+ approverCollaboratorId: approverId,
1666
+ });
1667
+ });
1668
+
1669
+ return this.listSingleTimesheet(actor, timesheetId);
1670
+ }
1671
+
1672
+ async listTimeOffRequests(userId: number) {
1673
+ const actor = await this.getActorContext(userId);
1674
+ const filter = this.buildIdFilter(actor.visibleCollaboratorIds, 'tor.collaborator_id', actor.isDirector);
1675
+
1676
+ return this.queryRows(
1677
+ `SELECT tor.id,
1678
+ tor.collaborator_id AS "collaboratorId",
1679
+ c.display_name AS "collaboratorName",
1680
+ tor.approver_collaborator_id AS "approverCollaboratorId",
1681
+ a.display_name AS "approverName",
1682
+ tor.request_type AS "requestType",
1683
+ tor.start_date AS "startDate",
1684
+ tor.end_date AS "endDate",
1685
+ tor.total_days AS "totalDays",
1686
+ tor.status,
1687
+ tor.reason,
1688
+ tor.submitted_at AS "submittedAt",
1689
+ tor.reviewed_at AS "reviewedAt",
1690
+ approval.decision_note AS "approverNote"
1691
+ FROM operations_time_off_request tor
1692
+ JOIN operations_collaborator c ON c.id = tor.collaborator_id
1693
+ LEFT JOIN operations_collaborator a ON a.id = tor.approver_collaborator_id
1694
+ LEFT JOIN operations_approval approval
1695
+ ON approval.target_type = 'time_off_request'
1696
+ AND approval.target_id = tor.id
1697
+ AND approval.deleted_at IS NULL
1698
+ WHERE tor.deleted_at IS NULL AND ${filter.clause}
1699
+ ORDER BY tor.start_date DESC, tor.id DESC`,
1700
+ filter.params
1701
+ );
1702
+ }
1703
+
1704
+ async createTimeOffRequest(userId: number, data: TimeOffPayload) {
1705
+ const actor = await this.getActorContext(userId);
1706
+ this.ensureCollaborator(actor);
1707
+ this.requireFields(data, ['startDate', 'endDate']);
1708
+
1709
+ const collaboratorId =
1710
+ actor.isDirector && data.collaboratorId
1711
+ ? data.collaboratorId
1712
+ : actor.collaboratorId;
1713
+ if (!collaboratorId) {
1714
+ throw new BadRequestException('Collaborator context is required.');
1715
+ }
1716
+ if (!actor.isDirector && collaboratorId !== actor.collaboratorId) {
1717
+ throw new ForbiddenException('You can only create your own time-off requests.');
1718
+ }
1719
+
1720
+ const collaborator = await this.getCollaboratorById(collaboratorId);
1721
+ const created = await this.prisma.$transaction(async (tx) => {
1722
+ const row = (await (tx as any).$queryRawUnsafe(
1723
+ `INSERT INTO operations_time_off_request (
1724
+ collaborator_id,
1725
+ approver_collaborator_id,
1726
+ request_type,
1727
+ start_date,
1728
+ end_date,
1729
+ total_days,
1730
+ status,
1731
+ reason,
1732
+ submitted_at,
1733
+ created_at,
1734
+ updated_at
1735
+ ) VALUES ($1, $2, COALESCE($3, 'vacation'), $4, $5, $6, 'submitted', $7, NOW(), NOW(), NOW())
1736
+ RETURNING id`,
1737
+ collaboratorId,
1738
+ collaborator.supervisorId ?? null,
1739
+ data.requestType ?? 'vacation',
1740
+ data.startDate,
1741
+ data.endDate,
1742
+ data.totalDays ?? null,
1743
+ data.reason ?? null
1744
+ )) as { id: number }[];
1745
+ const requestId = row[0]?.id;
1746
+ await this.upsertApproval(tx as any, {
1747
+ targetType: 'time_off_request',
1748
+ targetId: requestId,
1749
+ requesterCollaboratorId: collaboratorId,
1750
+ approverCollaboratorId: collaborator.supervisorId ?? null,
1751
+ });
1752
+ return requestId;
1753
+ });
1754
+
1755
+ return this.querySingle(
1756
+ `SELECT id,
1757
+ collaborator_id AS "collaboratorId",
1758
+ approver_collaborator_id AS "approverCollaboratorId",
1759
+ request_type AS "requestType",
1760
+ start_date AS "startDate",
1761
+ end_date AS "endDate",
1762
+ total_days AS "totalDays",
1763
+ status,
1764
+ reason,
1765
+ submitted_at AS "submittedAt",
1766
+ reviewed_at AS "reviewedAt"
1767
+ FROM operations_time_off_request
1768
+ WHERE id = $1`,
1769
+ [created]
1770
+ );
1771
+ }
1772
+
1773
+ async listScheduleAdjustments(userId: number) {
1774
+ const actor = await this.getActorContext(userId);
1775
+ const filter = this.buildIdFilter(actor.visibleCollaboratorIds, 'sar.collaborator_id', actor.isDirector);
1776
+
1777
+ const requests = await this.queryRows<{
1778
+ id: number;
1779
+ collaboratorId: number;
1780
+ collaboratorName: string;
1781
+ approverCollaboratorId: number | null;
1782
+ approverName: string | null;
1783
+ requestScope: string;
1784
+ effectiveStartDate: string;
1785
+ effectiveEndDate: string | null;
1786
+ status: string;
1787
+ reason: string | null;
1788
+ submittedAt: string | null;
1789
+ reviewedAt: string | null;
1790
+ approverNote: string | null;
1791
+ }>(
1792
+ `SELECT sar.id,
1793
+ sar.collaborator_id AS "collaboratorId",
1794
+ c.display_name AS "collaboratorName",
1795
+ sar.approver_collaborator_id AS "approverCollaboratorId",
1796
+ a.display_name AS "approverName",
1797
+ sar.request_scope AS "requestScope",
1798
+ sar.effective_start_date AS "effectiveStartDate",
1799
+ sar.effective_end_date AS "effectiveEndDate",
1800
+ sar.status,
1801
+ sar.reason,
1802
+ sar.submitted_at AS "submittedAt",
1803
+ sar.reviewed_at AS "reviewedAt",
1804
+ approval.decision_note AS "approverNote"
1805
+ FROM operations_schedule_adjustment_request sar
1806
+ JOIN operations_collaborator c ON c.id = sar.collaborator_id
1807
+ LEFT JOIN operations_collaborator a ON a.id = sar.approver_collaborator_id
1808
+ LEFT JOIN operations_approval approval
1809
+ ON approval.target_type = 'schedule_adjustment_request'
1810
+ AND approval.target_id = sar.id
1811
+ AND approval.deleted_at IS NULL
1812
+ WHERE sar.deleted_at IS NULL AND ${filter.clause}
1813
+ ORDER BY sar.effective_start_date DESC, sar.id DESC`,
1814
+ filter.params
1815
+ );
1816
+
1817
+ if (!requests.length) {
1818
+ return requests;
1819
+ }
1820
+
1821
+ const days = await this.queryRows<{
1822
+ requestId: number;
1823
+ weekday: string;
1824
+ isWorkingDay: boolean;
1825
+ startTime: string | null;
1826
+ endTime: string | null;
1827
+ breakMinutes: number | null;
1828
+ }>(
1829
+ `SELECT schedule_adjustment_request_id AS "requestId",
1830
+ weekday,
1831
+ is_working_day AS "isWorkingDay",
1832
+ start_time AS "startTime",
1833
+ end_time AS "endTime",
1834
+ break_minutes AS "breakMinutes"
1835
+ FROM operations_schedule_adjustment_day
1836
+ WHERE schedule_adjustment_request_id = ANY($1::int[])
1837
+ ORDER BY id ASC`,
1838
+ [requests.map((item) => item.id)]
1839
+ );
1840
+
1841
+ const currentSchedule = await this.queryRows<{
1842
+ collaboratorId: number;
1843
+ weekday: string;
1844
+ isWorkingDay: boolean;
1845
+ startTime: string | null;
1846
+ endTime: string | null;
1847
+ breakMinutes: number | null;
1848
+ }>(
1849
+ `SELECT collaborator_id AS "collaboratorId",
1850
+ weekday,
1851
+ is_working_day AS "isWorkingDay",
1852
+ start_time AS "startTime",
1853
+ end_time AS "endTime",
1854
+ break_minutes AS "breakMinutes"
1855
+ FROM operations_collaborator_schedule_day
1856
+ WHERE collaborator_id = ANY($1::int[])
1857
+ ORDER BY id ASC`,
1858
+ [this.uniqueNumbers(requests.map((item) => item.collaboratorId))]
1859
+ );
1860
+
1861
+ const grouped = this.groupBy(days, 'requestId');
1862
+ const currentScheduleByCollaborator = this.groupBy(
1863
+ currentSchedule,
1864
+ 'collaboratorId'
1865
+ );
1866
+ return requests.map((request) => ({
1867
+ ...request,
1868
+ days: grouped[request.id] ?? [],
1869
+ currentSchedule: currentScheduleByCollaborator[request.collaboratorId] ?? [],
1870
+ }));
1871
+ }
1872
+
1873
+ async createScheduleAdjustmentRequest(
1874
+ userId: number,
1875
+ data: ScheduleAdjustmentPayload
1876
+ ) {
1877
+ const actor = await this.getActorContext(userId);
1878
+ this.ensureCollaborator(actor);
1879
+ this.requireFields(data as unknown as Record<string, unknown>, ['effectiveStartDate', 'days']);
1880
+ if (!Array.isArray(data.days) || data.days.length === 0) {
1881
+ throw new BadRequestException('At least one schedule day is required.');
1882
+ }
1883
+
1884
+ const collaboratorId =
1885
+ actor.isDirector && data.collaboratorId
1886
+ ? data.collaboratorId
1887
+ : actor.collaboratorId;
1888
+ if (!collaboratorId) {
1889
+ throw new BadRequestException('Collaborator context is required.');
1890
+ }
1891
+ if (!actor.isDirector && collaboratorId !== actor.collaboratorId) {
1892
+ throw new ForbiddenException(
1893
+ 'You can only create your own schedule adjustments.'
1894
+ );
1895
+ }
1896
+
1897
+ const collaborator = await this.getCollaboratorById(collaboratorId);
1898
+ const created = await this.prisma.$transaction(async (tx) => {
1899
+ const row = (await (tx as any).$queryRawUnsafe(
1900
+ `INSERT INTO operations_schedule_adjustment_request (
1901
+ collaborator_id,
1902
+ approver_collaborator_id,
1903
+ request_scope,
1904
+ effective_start_date,
1905
+ effective_end_date,
1906
+ status,
1907
+ reason,
1908
+ submitted_at,
1909
+ created_at,
1910
+ updated_at
1911
+ ) VALUES (
1912
+ $1, $2, COALESCE($3, 'temporary'), $4, $5, 'submitted', $6, NOW(), NOW(), NOW()
1913
+ )
1914
+ RETURNING id`,
1915
+ collaboratorId,
1916
+ collaborator.supervisorId ?? null,
1917
+ data.requestScope ?? 'temporary',
1918
+ data.effectiveStartDate,
1919
+ data.effectiveEndDate ?? null,
1920
+ data.reason ?? null
1921
+ )) as { id: number }[];
1922
+ const requestId = row[0]?.id;
1923
+
1924
+ for (const day of data.days) {
1925
+ await (tx as any).$executeRawUnsafe(
1926
+ `INSERT INTO operations_schedule_adjustment_day (
1927
+ schedule_adjustment_request_id,
1928
+ weekday,
1929
+ is_working_day,
1930
+ start_time,
1931
+ end_time,
1932
+ break_minutes,
1933
+ created_at,
1934
+ updated_at
1935
+ ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
1936
+ requestId,
1937
+ day.weekday,
1938
+ day.isWorkingDay ?? true,
1939
+ day.startTime ?? null,
1940
+ day.endTime ?? null,
1941
+ day.breakMinutes ?? null
1942
+ );
1943
+ }
1944
+
1945
+ await this.upsertApproval(tx as any, {
1946
+ targetType: 'schedule_adjustment_request',
1947
+ targetId: requestId,
1948
+ requesterCollaboratorId: collaboratorId,
1949
+ approverCollaboratorId: collaborator.supervisorId ?? null,
1950
+ });
1951
+ return requestId;
1952
+ });
1953
+
1954
+ return this.querySingle(
1955
+ `SELECT id,
1956
+ collaborator_id AS "collaboratorId",
1957
+ approver_collaborator_id AS "approverCollaboratorId",
1958
+ request_scope AS "requestScope",
1959
+ effective_start_date AS "effectiveStartDate",
1960
+ effective_end_date AS "effectiveEndDate",
1961
+ status,
1962
+ reason,
1963
+ submitted_at AS "submittedAt",
1964
+ reviewed_at AS "reviewedAt"
1965
+ FROM operations_schedule_adjustment_request
1966
+ WHERE id = $1`,
1967
+ [created]
1968
+ );
1969
+ }
1970
+
1971
+ async listApprovals(userId: number) {
1972
+ const actor = await this.getActorContext(userId);
1973
+ this.ensureSupervisor(actor);
1974
+
1975
+ const params: unknown[] = [];
1976
+ const clause = actor.isDirector
1977
+ ? 'a.deleted_at IS NULL'
1978
+ : `a.deleted_at IS NULL AND a.approver_collaborator_id = ${this.param(
1979
+ params,
1980
+ actor.collaboratorId
1981
+ )}`;
1982
+
1983
+ return this.queryRows(
1984
+ `SELECT a.id,
1985
+ a.target_type AS "targetType",
1986
+ a.target_id AS "targetId",
1987
+ a.requester_collaborator_id AS "requesterCollaboratorId",
1988
+ requester.display_name AS "requesterName",
1989
+ a.approver_collaborator_id AS "approverCollaboratorId",
1990
+ approver.display_name AS "approverName",
1991
+ a.status,
1992
+ a.submitted_at AS "submittedAt",
1993
+ a.decided_at AS "decidedAt",
1994
+ a.decision_note AS "decisionNote",
1995
+ t.week_start_date AS "timesheetWeekStartDate",
1996
+ t.week_end_date AS "timesheetWeekEndDate",
1997
+ t.total_hours AS "timesheetTotalHours",
1998
+ COALESCE(
1999
+ STRING_AGG(DISTINCT p.name, ', ') FILTER (WHERE p.name IS NOT NULL),
2000
+ ''
2001
+ ) AS "timesheetProjectNames",
2002
+ tor.request_type AS "timeOffType",
2003
+ tor.start_date AS "timeOffStartDate",
2004
+ tor.end_date AS "timeOffEndDate",
2005
+ tor.reason AS "timeOffReason",
2006
+ sar.request_scope AS "scheduleRequestScope",
2007
+ sar.effective_start_date AS "scheduleStartDate",
2008
+ sar.effective_end_date AS "scheduleEndDate",
2009
+ sar.reason AS "scheduleReason"
2010
+ FROM operations_approval a
2011
+ JOIN operations_collaborator requester
2012
+ ON requester.id = a.requester_collaborator_id
2013
+ LEFT JOIN operations_collaborator approver
2014
+ ON approver.id = a.approver_collaborator_id
2015
+ LEFT JOIN operations_timesheet t
2016
+ ON a.target_type = 'timesheet'
2017
+ AND t.id = a.target_id
2018
+ LEFT JOIN operations_timesheet_entry te
2019
+ ON te.timesheet_id = t.id
2020
+ AND te.deleted_at IS NULL
2021
+ LEFT JOIN operations_project_assignment pa
2022
+ ON pa.id = te.project_assignment_id
2023
+ LEFT JOIN operations_project p
2024
+ ON p.id = pa.project_id
2025
+ LEFT JOIN operations_time_off_request tor
2026
+ ON a.target_type = 'time_off_request'
2027
+ AND tor.id = a.target_id
2028
+ LEFT JOIN operations_schedule_adjustment_request sar
2029
+ ON a.target_type = 'schedule_adjustment_request'
2030
+ AND sar.id = a.target_id
2031
+ WHERE ${clause}
2032
+ GROUP BY a.id, requester.id, approver.id, t.id, tor.id, sar.id
2033
+ ORDER BY a.submitted_at DESC, a.id DESC`,
2034
+ params
2035
+ );
2036
+ }
2037
+
2038
+ async approve(userId: number, approvalId: number, data: DecisionPayload) {
2039
+ return this.decideApproval(userId, approvalId, 'approve', data);
2040
+ }
2041
+
2042
+ async reject(userId: number, approvalId: number, data: DecisionPayload) {
2043
+ return this.decideApproval(userId, approvalId, 'reject', data);
2044
+ }
2045
+
2046
+ async publishAccountsPayableReference(
2047
+ userId: number,
2048
+ data: PublishAccountsPayableReferencePayload,
2049
+ ) {
2050
+ const actor = await this.getActorContext(userId);
2051
+ this.ensureSupervisor(actor);
2052
+
2053
+ const sourceEntityId = String(data?.sourceEntityId || '').trim();
2054
+ const sourceEntityType =
2055
+ String(data?.sourceEntityType || '').trim() ||
2056
+ 'operations_payable_request';
2057
+ const personId = Number(data?.personId);
2058
+ const totalAmount = Number(data?.totalAmount);
2059
+ const dueDate = String(data?.dueDate || '').trim();
2060
+ const documentNumber = String(data?.documentNumber || '').trim();
2061
+ const locale = String(data?.locale || '').trim() || 'en';
2062
+
2063
+ if (!sourceEntityId) {
2064
+ throw new BadRequestException('sourceEntityId is required.');
2065
+ }
2066
+ if (!Number.isInteger(personId) || personId <= 0) {
2067
+ throw new BadRequestException('personId must be a positive integer.');
2068
+ }
2069
+ if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
2070
+ throw new BadRequestException('totalAmount must be greater than zero.');
2071
+ }
2072
+ if (!dueDate || Number.isNaN(new Date(dueDate).getTime())) {
2073
+ throw new BadRequestException('dueDate must be a valid ISO date string.');
2074
+ }
2075
+ if (!documentNumber) {
2076
+ throw new BadRequestException('documentNumber is required.');
2077
+ }
2078
+
2079
+ const outboxEvent = await this.integrationApi.publishEvent({
2080
+ eventName: 'operations.accounts_payable.requested',
2081
+ sourceModule: 'operations',
2082
+ aggregateType: sourceEntityType,
2083
+ aggregateId: sourceEntityId,
2084
+ payload: {
2085
+ sourceEntityType,
2086
+ sourceEntityId,
2087
+ locale,
2088
+ requestedByUserId: userId,
2089
+ payable: {
2090
+ personId,
2091
+ dueDate,
2092
+ totalAmount,
2093
+ documentNumber,
2094
+ description: data?.description ?? null,
2095
+ paymentChannel: data?.paymentChannel ?? null,
2096
+ financeCategoryId: data?.financeCategoryId ?? null,
2097
+ costCenterId: data?.costCenterId ?? null,
2098
+ },
2099
+ },
2100
+ });
2101
+
2102
+ return {
2103
+ queued: true,
2104
+ eventId: outboxEvent.id,
2105
+ eventName: outboxEvent.eventName,
2106
+ sourceEntityType,
2107
+ sourceEntityId,
2108
+ };
2109
+ }
2110
+
2111
+ private async decideApproval(
2112
+ userId: number,
2113
+ approvalId: number,
2114
+ action: ApprovalAction,
2115
+ data: DecisionPayload
2116
+ ) {
2117
+ const actor = await this.getActorContext(userId);
2118
+ this.ensureSupervisor(actor);
2119
+
2120
+ const approval = await this.querySingle<{
2121
+ id: number;
2122
+ targetType: ApprovalTargetType;
2123
+ targetId: number;
2124
+ requesterCollaboratorId: number;
2125
+ approverCollaboratorId: number | null;
2126
+ status: string;
2127
+ }>(
2128
+ `SELECT id,
2129
+ target_type AS "targetType",
2130
+ target_id AS "targetId",
2131
+ requester_collaborator_id AS "requesterCollaboratorId",
2132
+ approver_collaborator_id AS "approverCollaboratorId",
2133
+ status
2134
+ FROM operations_approval
2135
+ WHERE id = $1
2136
+ AND deleted_at IS NULL`,
2137
+ [approvalId]
2138
+ );
2139
+ if (!approval) {
2140
+ throw new NotFoundException('Approval not found.');
2141
+ }
2142
+ if (!actor.isDirector && approval.approverCollaboratorId !== actor.collaboratorId) {
2143
+ throw new ForbiddenException('You cannot decide this approval.');
2144
+ }
2145
+ if (approval.status !== 'pending') {
2146
+ throw new BadRequestException('Only pending approvals can be decided.');
2147
+ }
2148
+
2149
+ const nextStatus = action === 'approve' ? 'approved' : 'rejected';
2150
+
2151
+ await this.prisma.$transaction(async (tx) => {
2152
+ await (tx as any).$executeRawUnsafe(
2153
+ `UPDATE operations_approval
2154
+ SET status = $1,
2155
+ decided_at = NOW(),
2156
+ decision_note = $2,
2157
+ updated_at = NOW()
2158
+ WHERE id = $3`,
2159
+ nextStatus,
2160
+ data.note ?? null,
2161
+ approvalId
2162
+ );
2163
+
2164
+ if (approval.targetType === 'timesheet') {
2165
+ await (tx as any).$executeRawUnsafe(
2166
+ `UPDATE operations_timesheet
2167
+ SET status = $1,
2168
+ reviewed_at = NOW(),
2169
+ approver_collaborator_id = $2,
2170
+ updated_at = NOW()
2171
+ WHERE id = $3`,
2172
+ nextStatus,
2173
+ actor.collaboratorId,
2174
+ approval.targetId
2175
+ );
2176
+ }
2177
+
2178
+ if (approval.targetType === 'time_off_request') {
2179
+ await (tx as any).$executeRawUnsafe(
2180
+ `UPDATE operations_time_off_request
2181
+ SET status = $1,
2182
+ reviewed_at = NOW(),
2183
+ approver_collaborator_id = $2,
2184
+ submitted_at = COALESCE(submitted_at, NOW()),
2185
+ updated_at = NOW()
2186
+ WHERE id = $3`,
2187
+ nextStatus,
2188
+ actor.collaboratorId,
2189
+ approval.targetId
2190
+ );
2191
+ }
2192
+
2193
+ if (approval.targetType === 'schedule_adjustment_request') {
2194
+ await (tx as any).$executeRawUnsafe(
2195
+ `UPDATE operations_schedule_adjustment_request
2196
+ SET status = $1,
2197
+ reviewed_at = NOW(),
2198
+ approver_collaborator_id = $2,
2199
+ submitted_at = COALESCE(submitted_at, NOW()),
2200
+ updated_at = NOW()
2201
+ WHERE id = $3`,
2202
+ nextStatus,
2203
+ actor.collaboratorId,
2204
+ approval.targetId
2205
+ );
2206
+ }
2207
+
2208
+ await this.insertApprovalHistory(
2209
+ tx as any,
2210
+ approvalId,
2211
+ actor.collaboratorId,
2212
+ nextStatus === 'approved' ? 'approved' : 'rejected',
2213
+ data.note ?? null
2214
+ );
2215
+ });
2216
+
2217
+ return this.querySingle(
2218
+ `SELECT id,
2219
+ target_type AS "targetType",
2220
+ target_id AS "targetId",
2221
+ status,
2222
+ decided_at AS "decidedAt",
2223
+ decision_note AS "decisionNote"
2224
+ FROM operations_approval
2225
+ WHERE id = $1`,
2226
+ [approvalId]
2227
+ );
2228
+ }
2229
+
2230
+ private async getActorContext(userId: number): Promise<ActorContext> {
2231
+ const roleSlugs = (
2232
+ await this.prisma.role.findMany({
2233
+ where: {
2234
+ role_user: {
2235
+ some: {
2236
+ user_id: userId,
2237
+ },
2238
+ },
2239
+ },
2240
+ select: {
2241
+ slug: true,
2242
+ },
2243
+ })
2244
+ ).map((role) => role.slug);
2245
+
2246
+ const isDirector =
2247
+ roleSlugs.includes('admin') || roleSlugs.includes(DIRECTOR_ROLE);
2248
+ const isSupervisor = isDirector || roleSlugs.includes(SUPERVISOR_ROLE);
2249
+ const isCollaborator = isSupervisor || roleSlugs.includes(COLLABORATOR_ROLE);
2250
+ const collaborator = await this.getCollaboratorByUserId(userId);
2251
+
2252
+ if (!collaborator && isCollaborator && !isDirector) {
2253
+ throw new NotFoundException(
2254
+ 'The authenticated user does not have a linked operations collaborator profile.'
2255
+ );
2256
+ }
2257
+
2258
+ const collaboratorId = collaborator?.id ?? null;
2259
+ const teamCollaboratorIds =
2260
+ isSupervisor && collaboratorId
2261
+ ? await this.getDirectReportIds(collaboratorId)
2262
+ : [];
2263
+
2264
+ return {
2265
+ userId,
2266
+ roleSlugs,
2267
+ collaboratorId,
2268
+ collaboratorName: collaborator?.displayName ?? null,
2269
+ isDirector,
2270
+ isSupervisor,
2271
+ isCollaborator,
2272
+ teamCollaboratorIds,
2273
+ visibleCollaboratorIds: this.uniqueNumbers(
2274
+ isDirector
2275
+ ? []
2276
+ : [collaboratorId, ...(isSupervisor ? teamCollaboratorIds : [])]
2277
+ ),
2278
+ visibleProjectIds: isDirector
2279
+ ? []
2280
+ : await this.getAssignedProjectIds(
2281
+ this.uniqueNumbers([
2282
+ collaboratorId,
2283
+ ...(isSupervisor ? teamCollaboratorIds : []),
2284
+ ])
2285
+ ),
2286
+ };
2287
+ }
2288
+
2289
+ private async getCollaboratorByUserId(userId: number) {
2290
+ return this.querySingle<{
2291
+ id: number;
2292
+ displayName: string;
2293
+ supervisorId: number | null;
2294
+ supervisorName: string | null;
2295
+ }>(
2296
+ `SELECT c.id,
2297
+ c.display_name AS "displayName",
2298
+ s.id AS "supervisorId",
2299
+ s.display_name AS "supervisorName"
2300
+ FROM operations_collaborator c
2301
+ LEFT JOIN operations_collaborator s
2302
+ ON s.id = c.supervisor_collaborator_id
2303
+ WHERE c.user_id = $1
2304
+ AND c.deleted_at IS NULL`,
2305
+ [userId]
2306
+ );
2307
+ }
2308
+
2309
+ private async getCollaboratorById(collaboratorId: number) {
2310
+ const collaborator = await this.querySingle<{
2311
+ id: number;
2312
+ displayName: string;
2313
+ supervisorId: number | null;
2314
+ supervisorName: string | null;
2315
+ }>(
2316
+ `SELECT c.id,
2317
+ c.display_name AS "displayName",
2318
+ s.id AS "supervisorId",
2319
+ s.display_name AS "supervisorName"
2320
+ FROM operations_collaborator c
2321
+ LEFT JOIN operations_collaborator s
2322
+ ON s.id = c.supervisor_collaborator_id
2323
+ WHERE c.id = $1
2324
+ AND c.deleted_at IS NULL`,
2325
+ [collaboratorId]
2326
+ );
2327
+ if (!collaborator) {
2328
+ throw new NotFoundException('Collaborator not found.');
2329
+ }
2330
+ return collaborator;
2331
+ }
2332
+
2333
+ private async getProjectDetails(
2334
+ projectId: number,
2335
+ actorCollaboratorId?: number | null
2336
+ ) {
2337
+ const project = await this.querySingle<{
2338
+ id: number;
2339
+ contractId: number | null;
2340
+ managerCollaboratorId: number | null;
2341
+ code: string;
2342
+ name: string;
2343
+ clientName: string | null;
2344
+ summary: string | null;
2345
+ status: string;
2346
+ progressPercent: number | null;
2347
+ deliveryModel: string | null;
2348
+ budgetAmount: number | null;
2349
+ startDate: string | null;
2350
+ endDate: string | null;
2351
+ contractName: string | null;
2352
+ contractStatus: string | null;
2353
+ contractCategory: string | null;
2354
+ managerName: string | null;
2355
+ myAssignmentId: number | null;
2356
+ myRoleLabel: string | null;
2357
+ teamSize: number;
2358
+ }>(
2359
+ `SELECT p.id,
2360
+ p.contract_id AS "contractId",
2361
+ p.manager_collaborator_id AS "managerCollaboratorId",
2362
+ p.code,
2363
+ p.name,
2364
+ p.client_name AS "clientName",
2365
+ p.summary,
2366
+ p.status,
2367
+ p.progress_percent AS "progressPercent",
2368
+ p.delivery_model AS "deliveryModel",
2369
+ p.budget_amount AS "budgetAmount",
2370
+ p.start_date AS "startDate",
2371
+ p.end_date AS "endDate",
2372
+ c.name AS "contractName",
2373
+ c.status AS "contractStatus",
2374
+ c.contract_category AS "contractCategory",
2375
+ m.display_name AS "managerName",
2376
+ MAX(CASE WHEN pa.collaborator_id = $2 THEN pa.id END)::int AS "myAssignmentId",
2377
+ MAX(CASE WHEN pa.collaborator_id = $2 THEN pa.role_label END) AS "myRoleLabel",
2378
+ COUNT(DISTINCT pa.id)::int AS "teamSize"
2379
+ FROM operations_project p
2380
+ LEFT JOIN operations_contract c ON c.id = p.contract_id
2381
+ LEFT JOIN operations_collaborator m ON m.id = p.manager_collaborator_id
2382
+ LEFT JOIN operations_project_assignment pa
2383
+ ON pa.project_id = p.id
2384
+ AND pa.deleted_at IS NULL
2385
+ WHERE p.id = $1
2386
+ AND p.deleted_at IS NULL
2387
+ GROUP BY p.id, c.id, m.id`,
2388
+ [projectId, actorCollaboratorId ?? null]
2389
+ );
2390
+
2391
+ if (!project) {
2392
+ throw new NotFoundException('Project not found.');
2393
+ }
2394
+
2395
+ const [assignments, relatedContract, timesheetSummary, operationalIndicators] =
2396
+ await Promise.all([
2397
+ this.queryRows<{
2398
+ id: number;
2399
+ collaboratorId: number;
2400
+ collaboratorName: string;
2401
+ roleLabel: string | null;
2402
+ allocationPercent: number | null;
2403
+ weeklyHours: number | null;
2404
+ isBillable: boolean;
2405
+ startDate: string | null;
2406
+ endDate: string | null;
2407
+ status: string;
2408
+ }>(
2409
+ `SELECT pa.id,
2410
+ pa.collaborator_id AS "collaboratorId",
2411
+ c.display_name AS "collaboratorName",
2412
+ pa.role_label AS "roleLabel",
2413
+ pa.allocation_percent AS "allocationPercent",
2414
+ pa.weekly_hours AS "weeklyHours",
2415
+ pa.is_billable AS "isBillable",
2416
+ pa.start_date AS "startDate",
2417
+ pa.end_date AS "endDate",
2418
+ pa.status
2419
+ FROM operations_project_assignment pa
2420
+ JOIN operations_collaborator c ON c.id = pa.collaborator_id
2421
+ WHERE pa.project_id = $1
2422
+ AND pa.deleted_at IS NULL
2423
+ ORDER BY c.display_name ASC`,
2424
+ [projectId]
2425
+ ),
2426
+ project.contractId
2427
+ ? this.querySingle<{
2428
+ id: number;
2429
+ code: string;
2430
+ name: string;
2431
+ clientName: string;
2432
+ contractCategory: string;
2433
+ billingModel: string;
2434
+ status: string;
2435
+ startDate: string;
2436
+ endDate: string | null;
2437
+ budgetAmount: number | null;
2438
+ monthlyHourCap: number | null;
2439
+ description: string | null;
2440
+ originType: string | null;
2441
+ originId: number | null;
2442
+ }>(
2443
+ `SELECT id,
2444
+ code,
2445
+ name,
2446
+ client_name AS "clientName",
2447
+ contract_category AS "contractCategory",
2448
+ billing_model AS "billingModel",
2449
+ status,
2450
+ start_date AS "startDate",
2451
+ end_date AS "endDate",
2452
+ budget_amount AS "budgetAmount",
2453
+ monthly_hour_cap AS "monthlyHourCap",
2454
+ description,
2455
+ origin_type AS "originType",
2456
+ origin_id AS "originId"
2457
+ FROM operations_contract
2458
+ WHERE id = $1
2459
+ AND deleted_at IS NULL`,
2460
+ [project.contractId]
2461
+ )
2462
+ : Promise.resolve(null),
2463
+ this.querySingle<{
2464
+ totalTimesheets: string;
2465
+ pendingTimesheets: string;
2466
+ totalHours: string | null;
2467
+ }>(
2468
+ `SELECT COUNT(DISTINCT t.id)::text AS "totalTimesheets",
2469
+ COUNT(DISTINCT t.id) FILTER (WHERE t.status = 'submitted')::text AS "pendingTimesheets",
2470
+ COALESCE(SUM(e.hours), 0)::text AS "totalHours"
2471
+ FROM operations_project_assignment pa
2472
+ LEFT JOIN operations_timesheet_entry e
2473
+ ON e.project_assignment_id = pa.id
2474
+ AND e.deleted_at IS NULL
2475
+ LEFT JOIN operations_timesheet t
2476
+ ON t.id = e.timesheet_id
2477
+ AND t.deleted_at IS NULL
2478
+ WHERE pa.project_id = $1
2479
+ AND pa.deleted_at IS NULL`,
2480
+ [projectId]
2481
+ ),
2482
+ this.querySingle<{
2483
+ activeAssignments: string;
2484
+ billableAssignments: string;
2485
+ averageAllocation: string | null;
2486
+ totalWeeklyHours: string | null;
2487
+ }>(
2488
+ `SELECT COUNT(*) FILTER (WHERE status IN ('planned', 'active'))::text AS "activeAssignments",
2489
+ COUNT(*) FILTER (WHERE is_billable = true AND status IN ('planned', 'active'))::text AS "billableAssignments",
2490
+ COALESCE(AVG(allocation_percent), 0)::text AS "averageAllocation",
2491
+ COALESCE(SUM(weekly_hours), 0)::text AS "totalWeeklyHours"
2492
+ FROM operations_project_assignment
2493
+ WHERE project_id = $1
2494
+ AND deleted_at IS NULL`,
2495
+ [projectId]
2496
+ ),
2497
+ ]);
2498
+
2499
+ return {
2500
+ ...project,
2501
+ assignments,
2502
+ relatedContract,
2503
+ timesheetSummary: {
2504
+ totalTimesheets: Number(timesheetSummary?.totalTimesheets ?? 0),
2505
+ pendingTimesheets: Number(timesheetSummary?.pendingTimesheets ?? 0),
2506
+ totalHours: Number(timesheetSummary?.totalHours ?? 0),
2507
+ },
2508
+ operationalIndicators: {
2509
+ activeAssignments: Number(operationalIndicators?.activeAssignments ?? 0),
2510
+ billableAssignments: Number(
2511
+ operationalIndicators?.billableAssignments ?? 0
2512
+ ),
2513
+ averageAllocation: Number(
2514
+ operationalIndicators?.averageAllocation ?? 0
2515
+ ),
2516
+ totalWeeklyHours: Number(operationalIndicators?.totalWeeklyHours ?? 0),
2517
+ },
2518
+ };
2519
+ }
2520
+
2521
+ private async getCollaboratorDetails(collaboratorId: number) {
2522
+ const collaborator = await this.querySingle<{
2523
+ id: number;
2524
+ userId: number;
2525
+ code: string;
2526
+ collaboratorType: string;
2527
+ displayName: string;
2528
+ department: string | null;
2529
+ title: string | null;
2530
+ levelLabel: string | null;
2531
+ weeklyCapacityHours: number | null;
2532
+ status: string;
2533
+ joinedAt: string | null;
2534
+ leftAt: string | null;
2535
+ notes: string | null;
2536
+ supervisorId: number | null;
2537
+ supervisorName: string | null;
2538
+ contractId: number | null;
2539
+ contractStatus: string | null;
2540
+ activeAssignments: number;
2541
+ }>(
2542
+ `SELECT c.id,
2543
+ c.user_id AS "userId",
2544
+ c.code,
2545
+ c.collaborator_type AS "collaboratorType",
2546
+ c.display_name AS "displayName",
2547
+ c.department,
2548
+ c.title,
2549
+ c.level_label AS "levelLabel",
2550
+ c.weekly_capacity_hours AS "weeklyCapacityHours",
2551
+ c.status,
2552
+ c.joined_at AS "joinedAt",
2553
+ c.left_at AS "leftAt",
2554
+ c.notes,
2555
+ s.id AS "supervisorId",
2556
+ s.display_name AS "supervisorName",
2557
+ hiring_contract.id AS "contractId",
2558
+ hiring_contract.status AS "contractStatus",
2559
+ COUNT(DISTINCT pa.id)::int AS "activeAssignments"
2560
+ FROM operations_collaborator c
2561
+ LEFT JOIN operations_collaborator s
2562
+ ON s.id = c.supervisor_collaborator_id
2563
+ LEFT JOIN operations_project_assignment pa
2564
+ ON pa.collaborator_id = c.id
2565
+ AND pa.deleted_at IS NULL
2566
+ AND pa.status IN ('planned', 'active')
2567
+ LEFT JOIN LATERAL (
2568
+ SELECT oc.id, oc.status
2569
+ FROM operations_contract oc
2570
+ WHERE oc.related_collaborator_id = c.id
2571
+ AND oc.deleted_at IS NULL
2572
+ ORDER BY CASE WHEN oc.origin_type = 'employee_hiring' THEN 0 ELSE 1 END,
2573
+ oc.created_at DESC
2574
+ LIMIT 1
2575
+ ) hiring_contract ON TRUE
2576
+ WHERE c.id = $1
2577
+ AND c.deleted_at IS NULL
2578
+ GROUP BY c.id, s.id, hiring_contract.id, hiring_contract.status`,
2579
+ [collaboratorId]
2580
+ );
2581
+
2582
+ if (!collaborator) {
2583
+ throw new NotFoundException('Collaborator not found.');
2584
+ }
2585
+
2586
+ const [assignedProjects, relatedContracts, weeklySchedule, timesheetSummary, timeOffSummary, scheduleAdjustmentRequests] =
2587
+ await Promise.all([
2588
+ this.queryRows(
2589
+ `SELECT p.id,
2590
+ p.code,
2591
+ p.name,
2592
+ p.status,
2593
+ pa.role_label AS "roleLabel",
2594
+ pa.allocation_percent AS "allocationPercent",
2595
+ pa.weekly_hours AS "weeklyHours",
2596
+ pa.start_date AS "startDate",
2597
+ pa.end_date AS "endDate"
2598
+ FROM operations_project_assignment pa
2599
+ JOIN operations_project p ON p.id = pa.project_id
2600
+ WHERE pa.collaborator_id = $1
2601
+ AND pa.deleted_at IS NULL
2602
+ AND p.deleted_at IS NULL
2603
+ ORDER BY p.name ASC`,
2604
+ [collaboratorId]
2605
+ ),
2606
+ this.queryRows(
2607
+ `SELECT c.id,
2608
+ c.code,
2609
+ c.name,
2610
+ c.contract_category AS "contractCategory",
2611
+ c.client_name AS "clientName",
2612
+ c.billing_model AS "billingModel",
2613
+ c.start_date AS "startDate",
2614
+ c.end_date AS "endDate",
2615
+ c.budget_amount AS "budgetAmount",
2616
+ c.monthly_hour_cap AS "monthlyHourCap",
2617
+ c.status,
2618
+ c.origin_type AS "originType",
2619
+ c.origin_id AS "originId",
2620
+ c.description
2621
+ FROM operations_contract c
2622
+ WHERE c.related_collaborator_id = $1
2623
+ AND c.deleted_at IS NULL
2624
+ ORDER BY c.created_at DESC`,
2625
+ [collaboratorId]
2626
+ ),
2627
+ this.queryRows(
2628
+ `SELECT weekday,
2629
+ is_working_day AS "isWorkingDay",
2630
+ start_time AS "startTime",
2631
+ end_time AS "endTime",
2632
+ break_minutes AS "breakMinutes"
2633
+ FROM operations_collaborator_schedule_day
2634
+ WHERE collaborator_id = $1
2635
+ AND deleted_at IS NULL
2636
+ ORDER BY id ASC`,
2637
+ [collaboratorId]
2638
+ ),
2639
+ this.querySingle<{
2640
+ totalTimesheets: string;
2641
+ pendingTimesheets: string;
2642
+ totalHours: string | null;
2643
+ }>(
2644
+ `SELECT COUNT(*)::text AS "totalTimesheets",
2645
+ COUNT(*) FILTER (WHERE status = 'submitted')::text AS "pendingTimesheets",
2646
+ COALESCE(SUM(total_hours), 0)::text AS "totalHours"
2647
+ FROM operations_timesheet
2648
+ WHERE collaborator_id = $1
2649
+ AND deleted_at IS NULL`,
2650
+ [collaboratorId]
2651
+ ),
2652
+ this.querySingle<{
2653
+ totalRequests: string;
2654
+ pendingRequests: string;
2655
+ approvedRequests: string;
2656
+ }>(
2657
+ `SELECT COUNT(*)::text AS "totalRequests",
2658
+ COUNT(*) FILTER (WHERE status = 'submitted')::text AS "pendingRequests",
2659
+ COUNT(*) FILTER (WHERE status = 'approved')::text AS "approvedRequests"
2660
+ FROM operations_time_off_request
2661
+ WHERE collaborator_id = $1
2662
+ AND deleted_at IS NULL`,
2663
+ [collaboratorId]
2664
+ ),
2665
+ this.queryRows(
2666
+ `SELECT id,
2667
+ request_scope AS "requestScope",
2668
+ effective_start_date AS "effectiveStartDate",
2669
+ effective_end_date AS "effectiveEndDate",
2670
+ status,
2671
+ reason
2672
+ FROM operations_schedule_adjustment_request
2673
+ WHERE collaborator_id = $1
2674
+ AND deleted_at IS NULL
2675
+ ORDER BY effective_start_date DESC, id DESC`,
2676
+ [collaboratorId]
2677
+ ),
2678
+ ]);
2679
+
2680
+ return {
2681
+ ...collaborator,
2682
+ assignedProjects,
2683
+ relatedContracts,
2684
+ weeklySchedule,
2685
+ timesheetSummary: {
2686
+ totalTimesheets: Number(timesheetSummary?.totalTimesheets ?? 0),
2687
+ pendingTimesheets: Number(timesheetSummary?.pendingTimesheets ?? 0),
2688
+ totalHours: Number(timesheetSummary?.totalHours ?? 0),
2689
+ },
2690
+ timeOffSummary: {
2691
+ totalRequests: Number(timeOffSummary?.totalRequests ?? 0),
2692
+ pendingRequests: Number(timeOffSummary?.pendingRequests ?? 0),
2693
+ approvedRequests: Number(timeOffSummary?.approvedRequests ?? 0),
2694
+ },
2695
+ scheduleAdjustmentRequests,
2696
+ };
2697
+ }
2698
+
2699
+ private async getDirectReportIds(collaboratorId: number) {
2700
+ return (
2701
+ await this.queryRows<{ id: number }>(
2702
+ `SELECT id
2703
+ FROM operations_collaborator
2704
+ WHERE supervisor_collaborator_id = $1
2705
+ AND deleted_at IS NULL
2706
+ ORDER BY id ASC`,
2707
+ [collaboratorId]
2708
+ )
2709
+ ).map((row) => row.id);
2710
+ }
2711
+
2712
+ private async getAssignedProjectIds(collaboratorIds: number[]) {
2713
+ if (!collaboratorIds.length) return [];
2714
+ return (
2715
+ await this.queryRows<{ projectId: number }>(
2716
+ `SELECT DISTINCT project_id AS "projectId"
2717
+ FROM operations_project_assignment
2718
+ WHERE deleted_at IS NULL
2719
+ AND status IN ('planned', 'active')
2720
+ AND collaborator_id = ANY($1::int[])`,
2721
+ [collaboratorIds]
2722
+ )
2723
+ ).map((row) => row.projectId);
2724
+ }
2725
+
2726
+ private async getTimesheetById(timesheetId: number) {
2727
+ const timesheet = await this.querySingle<{
2728
+ id: number;
2729
+ collaboratorId: number;
2730
+ approverCollaboratorId: number | null;
2731
+ status: string;
2732
+ }>(
2733
+ `SELECT id,
2734
+ collaborator_id AS "collaboratorId",
2735
+ approver_collaborator_id AS "approverCollaboratorId",
2736
+ status
2737
+ FROM operations_timesheet
2738
+ WHERE id = $1
2739
+ AND deleted_at IS NULL`,
2740
+ [timesheetId]
2741
+ );
2742
+ if (!timesheet) {
2743
+ throw new NotFoundException('Timesheet not found.');
2744
+ }
2745
+ return timesheet;
2746
+ }
2747
+
2748
+ private async replaceTimesheetEntries(
2749
+ client: any,
2750
+ timesheetId: number,
2751
+ entries: TimesheetEntryPayload[],
2752
+ collaboratorId: number
2753
+ ) {
2754
+ await client.$executeRawUnsafe(
2755
+ `UPDATE operations_timesheet_entry
2756
+ SET deleted_at = NOW()
2757
+ WHERE timesheet_id = $1
2758
+ AND deleted_at IS NULL`,
2759
+ timesheetId
2760
+ );
2761
+
2762
+ if (!entries.length) return;
2763
+
2764
+ const assignmentIds = entries
2765
+ .map((entry) => entry.projectAssignmentId)
2766
+ .filter((value): value is number => typeof value === 'number');
2767
+
2768
+ if (assignmentIds.length) {
2769
+ const assignments = (await client.$queryRawUnsafe(
2770
+ `SELECT id, collaborator_id AS "collaboratorId"
2771
+ FROM operations_project_assignment
2772
+ WHERE id = ANY($1::int[])
2773
+ AND deleted_at IS NULL`,
2774
+ assignmentIds
2775
+ )) as { id: number; collaboratorId: number }[];
2776
+ const assignmentMap = new Map(
2777
+ assignments.map((assignment) => [
2778
+ assignment.id,
2779
+ assignment.collaboratorId,
2780
+ ])
2781
+ );
2782
+ for (const assignmentId of assignmentIds) {
2783
+ if (assignmentMap.get(assignmentId) !== collaboratorId) {
2784
+ throw new ForbiddenException(
2785
+ 'Timesheet entries must use assignments owned by the target collaborator.'
2786
+ );
2787
+ }
2788
+ }
2789
+ }
2790
+
2791
+ for (const entry of entries) {
2792
+ await client.$executeRawUnsafe(
2793
+ `INSERT INTO operations_timesheet_entry (
2794
+ timesheet_id,
2795
+ project_assignment_id,
2796
+ activity_label,
2797
+ work_date,
2798
+ hours,
2799
+ description,
2800
+ created_at,
2801
+ updated_at
2802
+ ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
2803
+ timesheetId,
2804
+ entry.projectAssignmentId ?? null,
2805
+ entry.activityLabel ?? null,
2806
+ entry.workDate,
2807
+ entry.hours,
2808
+ entry.description ?? null
2809
+ );
2810
+ }
2811
+ }
2812
+
2813
+ private async refreshTimesheetTotal(client: any, timesheetId: number) {
2814
+ await client.$executeRawUnsafe(
2815
+ `UPDATE operations_timesheet
2816
+ SET total_hours = (
2817
+ SELECT COALESCE(SUM(hours), 0)
2818
+ FROM operations_timesheet_entry
2819
+ WHERE timesheet_id = $1
2820
+ AND deleted_at IS NULL
2821
+ ),
2822
+ updated_at = NOW()
2823
+ WHERE id = $1`,
2824
+ timesheetId
2825
+ );
2826
+ }
2827
+
2828
+ private async upsertApproval(
2829
+ client: any,
2830
+ input: {
2831
+ targetType: ApprovalTargetType;
2832
+ targetId: number;
2833
+ requesterCollaboratorId: number;
2834
+ approverCollaboratorId: number | null;
2835
+ }
2836
+ ) {
2837
+ const existing = (await client.$queryRawUnsafe(
2838
+ `SELECT id
2839
+ FROM operations_approval
2840
+ WHERE target_type = $1
2841
+ AND target_id = $2
2842
+ AND deleted_at IS NULL`,
2843
+ input.targetType,
2844
+ input.targetId
2845
+ )) as { id: number }[];
2846
+
2847
+ let approvalId = existing[0]?.id;
2848
+ if (!approvalId) {
2849
+ const created = (await client.$queryRawUnsafe(
2850
+ `INSERT INTO operations_approval (
2851
+ target_type,
2852
+ target_id,
2853
+ requester_collaborator_id,
2854
+ approver_collaborator_id,
2855
+ status,
2856
+ submitted_at,
2857
+ created_at,
2858
+ updated_at
2859
+ ) VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW(), NOW())
2860
+ RETURNING id`,
2861
+ input.targetType,
2862
+ input.targetId,
2863
+ input.requesterCollaboratorId,
2864
+ input.approverCollaboratorId
2865
+ )) as { id: number }[];
2866
+ approvalId = created[0]?.id;
2867
+ await this.insertApprovalHistory(
2868
+ client,
2869
+ approvalId,
2870
+ input.requesterCollaboratorId,
2871
+ 'created',
2872
+ null
2873
+ );
2874
+ } else {
2875
+ await client.$executeRawUnsafe(
2876
+ `UPDATE operations_approval
2877
+ SET requester_collaborator_id = $1,
2878
+ approver_collaborator_id = $2,
2879
+ status = 'pending',
2880
+ submitted_at = NOW(),
2881
+ decided_at = NULL,
2882
+ decision_note = NULL,
2883
+ updated_at = NOW()
2884
+ WHERE id = $3`,
2885
+ input.requesterCollaboratorId,
2886
+ input.approverCollaboratorId,
2887
+ approvalId
2888
+ );
2889
+ await this.insertApprovalHistory(
2890
+ client,
2891
+ approvalId,
2892
+ input.requesterCollaboratorId,
2893
+ 'reopened',
2894
+ null
2895
+ );
2896
+ }
2897
+
2898
+ await this.insertApprovalHistory(
2899
+ client,
2900
+ approvalId,
2901
+ input.requesterCollaboratorId,
2902
+ 'submitted',
2903
+ null
2904
+ );
2905
+ }
2906
+
2907
+ private async insertApprovalHistory(
2908
+ client: any,
2909
+ approvalId: number,
2910
+ actorCollaboratorId: number | null,
2911
+ action:
2912
+ | 'created'
2913
+ | 'submitted'
2914
+ | 'approved'
2915
+ | 'rejected'
2916
+ | 'cancelled'
2917
+ | 'reopened',
2918
+ note: string | null
2919
+ ) {
2920
+ await client.$executeRawUnsafe(
2921
+ `INSERT INTO operations_approval_history (
2922
+ approval_id,
2923
+ actor_collaborator_id,
2924
+ action,
2925
+ note,
2926
+ created_at
2927
+ ) VALUES ($1, $2, $3, $4, NOW())`,
2928
+ approvalId,
2929
+ actorCollaboratorId,
2930
+ action,
2931
+ note
2932
+ );
2933
+ }
2934
+
2935
+ private async assertProjectAccess(actor: ActorContext, projectId: number) {
2936
+ if (actor.isDirector) return;
2937
+ if (!actor.visibleProjectIds.includes(projectId)) {
2938
+ throw new ForbiddenException('You do not have access to this project.');
2939
+ }
2940
+ }
2941
+
2942
+ private ensureCollaboratorAccess(actor: ActorContext, collaboratorId: number) {
2943
+ if (actor.isDirector) {
2944
+ return;
2945
+ }
2946
+
2947
+ if (!actor.visibleCollaboratorIds.includes(collaboratorId)) {
2948
+ throw new ForbiddenException(
2949
+ 'You do not have access to this collaborator.'
2950
+ );
2951
+ }
2952
+ }
2953
+
2954
+ private async listSingleTimesheet(actor: ActorContext, timesheetId: number) {
2955
+ const timesheets = await this.listTimesheets(actor.userId);
2956
+ const timesheet = (timesheets as any[]).find((item) => item.id === timesheetId);
2957
+ if (!timesheet) {
2958
+ throw new NotFoundException('Timesheet not found.');
2959
+ }
2960
+ return timesheet;
2961
+ }
2962
+
2963
+ private ensureDirector(actor: ActorContext) {
2964
+ if (!actor.isDirector) {
2965
+ throw new ForbiddenException('Director access is required.');
2966
+ }
2967
+ }
2968
+
2969
+ private ensureSupervisor(actor: ActorContext) {
2970
+ if (!actor.isSupervisor) {
2971
+ throw new ForbiddenException('Supervisor access is required.');
2972
+ }
2973
+ }
2974
+
2975
+ private ensureCollaborator(actor: ActorContext) {
2976
+ if (!actor.isCollaborator) {
2977
+ throw new ForbiddenException('Operations collaborator access is required.');
2978
+ }
2979
+ }
2980
+
2981
+ private defaultWeeklySchedule() {
2982
+ return [
2983
+ {
2984
+ weekday: 'monday',
2985
+ isWorkingDay: true,
2986
+ startTime: '09:00',
2987
+ endTime: '18:00',
2988
+ breakMinutes: 60,
2989
+ },
2990
+ {
2991
+ weekday: 'tuesday',
2992
+ isWorkingDay: true,
2993
+ startTime: '09:00',
2994
+ endTime: '18:00',
2995
+ breakMinutes: 60,
2996
+ },
2997
+ {
2998
+ weekday: 'wednesday',
2999
+ isWorkingDay: true,
3000
+ startTime: '09:00',
3001
+ endTime: '18:00',
3002
+ breakMinutes: 60,
3003
+ },
3004
+ {
3005
+ weekday: 'thursday',
3006
+ isWorkingDay: true,
3007
+ startTime: '09:00',
3008
+ endTime: '18:00',
3009
+ breakMinutes: 60,
3010
+ },
3011
+ {
3012
+ weekday: 'friday',
3013
+ isWorkingDay: true,
3014
+ startTime: '09:00',
3015
+ endTime: '18:00',
3016
+ breakMinutes: 60,
3017
+ },
3018
+ {
3019
+ weekday: 'saturday',
3020
+ isWorkingDay: false,
3021
+ startTime: null,
3022
+ endTime: null,
3023
+ breakMinutes: 0,
3024
+ },
3025
+ {
3026
+ weekday: 'sunday',
3027
+ isWorkingDay: false,
3028
+ startTime: null,
3029
+ endTime: null,
3030
+ breakMinutes: 0,
3031
+ },
3032
+ ] as const;
3033
+ }
3034
+
3035
+ private async replaceCollaboratorScheduleDays(
3036
+ client: any,
3037
+ collaboratorId: number,
3038
+ weeklySchedule?: CollaboratorPayload['weeklySchedule']
3039
+ ) {
3040
+ const schedule = weeklySchedule?.length
3041
+ ? weeklySchedule
3042
+ : this.defaultWeeklySchedule();
3043
+
3044
+ await client.$executeRawUnsafe(
3045
+ `UPDATE operations_collaborator_schedule_day
3046
+ SET deleted_at = NOW(),
3047
+ updated_at = NOW()
3048
+ WHERE collaborator_id = $1
3049
+ AND deleted_at IS NULL`,
3050
+ collaboratorId
3051
+ );
3052
+
3053
+ for (const day of schedule) {
3054
+ await client.$executeRawUnsafe(
3055
+ `INSERT INTO operations_collaborator_schedule_day (
3056
+ collaborator_id,
3057
+ weekday,
3058
+ is_working_day,
3059
+ start_time,
3060
+ end_time,
3061
+ break_minutes,
3062
+ created_at,
3063
+ updated_at
3064
+ ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
3065
+ collaboratorId,
3066
+ day.weekday,
3067
+ day.isWorkingDay ?? true,
3068
+ day.isWorkingDay === false ? null : day.startTime ?? null,
3069
+ day.isWorkingDay === false ? null : day.endTime ?? null,
3070
+ day.breakMinutes ?? null
3071
+ );
3072
+ }
3073
+ }
3074
+
3075
+ private async replaceProjectAssignments(
3076
+ client: any,
3077
+ projectId: number,
3078
+ teamAssignments?: ProjectPayload['teamAssignments']
3079
+ ) {
3080
+ await client.$executeRawUnsafe(
3081
+ `UPDATE operations_project_assignment
3082
+ SET deleted_at = NOW(),
3083
+ updated_at = NOW()
3084
+ WHERE project_id = $1
3085
+ AND deleted_at IS NULL`,
3086
+ projectId
3087
+ );
3088
+
3089
+ for (const assignment of teamAssignments ?? []) {
3090
+ await client.$executeRawUnsafe(
3091
+ `INSERT INTO operations_project_assignment (
3092
+ project_id,
3093
+ collaborator_id,
3094
+ role_label,
3095
+ allocation_percent,
3096
+ weekly_hours,
3097
+ is_billable,
3098
+ start_date,
3099
+ end_date,
3100
+ status,
3101
+ created_at,
3102
+ updated_at
3103
+ ) VALUES (
3104
+ $1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, 'active'), NOW(), NOW()
3105
+ )`,
3106
+ projectId,
3107
+ assignment.collaboratorId,
3108
+ assignment.roleLabel ?? 'Team Member',
3109
+ assignment.allocationPercent ?? null,
3110
+ assignment.weeklyHours ?? null,
3111
+ assignment.isBillable ?? true,
3112
+ assignment.startDate ?? null,
3113
+ assignment.endDate ?? null,
3114
+ assignment.status ?? 'active'
3115
+ );
3116
+ }
3117
+ }
3118
+
3119
+ private mapContractCategoryForCollaboratorType(
3120
+ collaboratorType: NonNullable<CollaboratorPayload['collaboratorType']>
3121
+ ) {
3122
+ switch (collaboratorType) {
3123
+ case 'clt':
3124
+ return 'employee';
3125
+ case 'pj':
3126
+ case 'freelancer':
3127
+ return 'contractor';
3128
+ default:
3129
+ return 'other';
3130
+ }
3131
+ }
3132
+
3133
+ private mapBillingModelForCollaboratorType(
3134
+ collaboratorType: NonNullable<CollaboratorPayload['collaboratorType']>
3135
+ ) {
3136
+ return collaboratorType === 'freelancer'
3137
+ ? 'time_and_material'
3138
+ : 'monthly_retainer';
3139
+ }
3140
+
3141
+ private mapContractTypeForCollaboratorType(
3142
+ collaboratorType: NonNullable<CollaboratorPayload['collaboratorType']>
3143
+ ) {
3144
+ switch (collaboratorType) {
3145
+ case 'clt':
3146
+ return 'clt';
3147
+ case 'pj':
3148
+ return 'pj';
3149
+ case 'freelancer':
3150
+ return 'freelancer_agreement';
3151
+ case 'intern':
3152
+ return 'fixed_term';
3153
+ default:
3154
+ return 'other';
3155
+ }
3156
+ }
3157
+
3158
+ private async createHiringContractDraft(
3159
+ client: any,
3160
+ createdByUserId: number,
3161
+ input: {
3162
+ collaboratorId: number;
3163
+ collaboratorCode: string;
3164
+ displayName: string;
3165
+ collaboratorType: NonNullable<CollaboratorPayload['collaboratorType']>;
3166
+ supervisorCollaboratorId: number | null;
3167
+ startDate: string | null;
3168
+ weeklyCapacityHours: number | null;
3169
+ compensationAmount: number | null;
3170
+ description: string | null;
3171
+ }
3172
+ ) {
3173
+ const contractCode = `HIR-${input.collaboratorCode}`;
3174
+ const contractName =
3175
+ input.collaboratorType === 'clt'
3176
+ ? `${input.displayName} Employment Contract`
3177
+ : `${input.displayName} Service Contract`;
3178
+
3179
+ await client.$executeRawUnsafe(
3180
+ `INSERT INTO operations_contract (
3181
+ code,
3182
+ name,
3183
+ contract_category,
3184
+ contract_type,
3185
+ client_name,
3186
+ signature_status,
3187
+ is_active,
3188
+ billing_model,
3189
+ account_manager_collaborator_id,
3190
+ related_collaborator_id,
3191
+ origin_type,
3192
+ origin_id,
3193
+ start_date,
3194
+ end_date,
3195
+ signed_at,
3196
+ effective_date,
3197
+ budget_amount,
3198
+ monthly_hour_cap,
3199
+ status,
3200
+ description,
3201
+ content_html,
3202
+ created_by_user_id,
3203
+ updated_by_user_id,
3204
+ created_at,
3205
+ updated_at
3206
+ ) VALUES (
3207
+ $1, $2, $3, $4, $5, 'not_started', true, $6, $7, $8, 'employee_hiring', $9, $10, NULL,
3208
+ NULL, $10, $11, $12, 'draft', $13, NULL, $14, $14, NOW(), NOW()
3209
+ )`,
3210
+ contractCode,
3211
+ contractName,
3212
+ this.mapContractCategoryForCollaboratorType(input.collaboratorType),
3213
+ this.mapContractTypeForCollaboratorType(input.collaboratorType),
3214
+ input.displayName,
3215
+ this.mapBillingModelForCollaboratorType(input.collaboratorType),
3216
+ input.supervisorCollaboratorId,
3217
+ input.collaboratorId,
3218
+ input.collaboratorId,
3219
+ input.startDate ?? new Date().toISOString().slice(0, 10),
3220
+ input.compensationAmount ?? null,
3221
+ input.weeklyCapacityHours
3222
+ ? Math.round(Number(input.weeklyCapacityHours) * 4)
3223
+ : null,
3224
+ input.description ?? null,
3225
+ createdByUserId
3226
+ );
3227
+ }
3228
+
3229
+ private async createProjectContractDraft(
3230
+ client: any,
3231
+ createdByUserId: number,
3232
+ input: {
3233
+ projectId: number;
3234
+ projectCode: string;
3235
+ projectName: string;
3236
+ clientName: string;
3237
+ managerCollaboratorId: number | null;
3238
+ startDate: string | null;
3239
+ endDate: string | null;
3240
+ budgetAmount: number | null;
3241
+ monthlyHourCap: number | null;
3242
+ billingModel:
3243
+ | 'time_and_material'
3244
+ | 'monthly_retainer'
3245
+ | 'fixed_price';
3246
+ contractCode: string | null;
3247
+ contractName: string | null;
3248
+ description: string | null;
3249
+ }
3250
+ ) {
3251
+ const created = await client.$queryRawUnsafe(
3252
+ `INSERT INTO operations_contract (
3253
+ code,
3254
+ name,
3255
+ contract_category,
3256
+ contract_type,
3257
+ client_name,
3258
+ signature_status,
3259
+ is_active,
3260
+ billing_model,
3261
+ account_manager_collaborator_id,
3262
+ related_collaborator_id,
3263
+ origin_type,
3264
+ origin_id,
3265
+ start_date,
3266
+ end_date,
3267
+ signed_at,
3268
+ effective_date,
3269
+ budget_amount,
3270
+ monthly_hour_cap,
3271
+ status,
3272
+ description,
3273
+ content_html,
3274
+ created_by_user_id,
3275
+ updated_by_user_id,
3276
+ created_at,
3277
+ updated_at
3278
+ ) VALUES (
3279
+ $1, $2, 'client', 'service_agreement', $3, 'not_started', true, $4, $5, NULL, 'client_project', $6,
3280
+ $7, $8, NULL, $7, $9, $10, 'draft', $11, NULL, $12, $12, NOW(), NOW()
3281
+ )
3282
+ RETURNING id`,
3283
+ input.contractCode ?? `PRJ-${input.projectCode}`,
3284
+ input.contractName ?? `${input.projectName} Service Agreement`,
3285
+ input.clientName,
3286
+ input.billingModel,
3287
+ input.managerCollaboratorId,
3288
+ input.projectId,
3289
+ input.startDate ?? new Date().toISOString().slice(0, 10),
3290
+ input.endDate ?? null,
3291
+ input.budgetAmount ?? null,
3292
+ input.monthlyHourCap ?? null,
3293
+ input.description ?? null,
3294
+ createdByUserId
3295
+ );
3296
+
3297
+ return (created as { id: number }[])[0]?.id;
3298
+ }
3299
+
3300
+ private async replaceContractParties(
3301
+ client: any,
3302
+ contractId: number,
3303
+ parties?: ContractPayload['parties']
3304
+ ) {
3305
+ await client.$executeRawUnsafe(
3306
+ `UPDATE operations_contract_party
3307
+ SET deleted_at = NOW(),
3308
+ updated_at = NOW()
3309
+ WHERE contract_id = $1
3310
+ AND deleted_at IS NULL`,
3311
+ contractId
3312
+ );
3313
+
3314
+ for (const party of parties ?? []) {
3315
+ await client.$executeRawUnsafe(
3316
+ `INSERT INTO operations_contract_party (
3317
+ contract_id,
3318
+ party_role,
3319
+ party_type,
3320
+ display_name,
3321
+ document_number,
3322
+ email,
3323
+ phone,
3324
+ is_primary,
3325
+ created_at,
3326
+ updated_at
3327
+ ) VALUES (
3328
+ $1, COALESCE($2, 'other'), COALESCE($3, 'company'), $4, $5, $6, $7, COALESCE($8, false), NOW(), NOW()
3329
+ )`,
3330
+ contractId,
3331
+ party.partyRole ?? 'other',
3332
+ party.partyType ?? 'company',
3333
+ party.displayName,
3334
+ party.documentNumber ?? null,
3335
+ party.email ?? null,
3336
+ party.phone ?? null,
3337
+ party.isPrimary ?? false
3338
+ );
3339
+ }
3340
+ }
3341
+
3342
+ private async replaceContractSignatures(
3343
+ client: any,
3344
+ contractId: number,
3345
+ signatures?: ContractPayload['signatures']
3346
+ ) {
3347
+ await client.$executeRawUnsafe(
3348
+ `UPDATE operations_contract_signature
3349
+ SET deleted_at = NOW(),
3350
+ updated_at = NOW()
3351
+ WHERE contract_id = $1
3352
+ AND deleted_at IS NULL`,
3353
+ contractId
3354
+ );
3355
+
3356
+ for (const signature of signatures ?? []) {
3357
+ await client.$executeRawUnsafe(
3358
+ `INSERT INTO operations_contract_signature (
3359
+ contract_id,
3360
+ signer_name,
3361
+ signer_role,
3362
+ signer_email,
3363
+ signer_status,
3364
+ signed_at,
3365
+ created_at,
3366
+ updated_at
3367
+ ) VALUES (
3368
+ $1, $2, $3, $4, COALESCE($5, 'pending'), $6, NOW(), NOW()
3369
+ )`,
3370
+ contractId,
3371
+ signature.signerName,
3372
+ signature.signerRole ?? null,
3373
+ signature.signerEmail ?? null,
3374
+ signature.status ?? 'pending',
3375
+ signature.signedAt ?? null
3376
+ );
3377
+ }
3378
+ }
3379
+
3380
+ private async replaceContractFinancialTerms(
3381
+ client: any,
3382
+ contractId: number,
3383
+ financialTerms?: ContractPayload['financialTerms']
3384
+ ) {
3385
+ await client.$executeRawUnsafe(
3386
+ `UPDATE operations_contract_financial_term
3387
+ SET deleted_at = NOW(),
3388
+ updated_at = NOW()
3389
+ WHERE contract_id = $1
3390
+ AND deleted_at IS NULL`,
3391
+ contractId
3392
+ );
3393
+
3394
+ for (const term of financialTerms ?? []) {
3395
+ await client.$executeRawUnsafe(
3396
+ `INSERT INTO operations_contract_financial_term (
3397
+ contract_id,
3398
+ term_type,
3399
+ label,
3400
+ amount,
3401
+ recurrence,
3402
+ due_day,
3403
+ notes,
3404
+ created_at,
3405
+ updated_at
3406
+ ) VALUES (
3407
+ $1, COALESCE($2, 'value'), $3, $4, COALESCE($5, 'one_time'), $6, $7, NOW(), NOW()
3408
+ )`,
3409
+ contractId,
3410
+ term.termType ?? 'value',
3411
+ term.label,
3412
+ term.amount,
3413
+ term.recurrence ?? 'one_time',
3414
+ term.dueDay ?? null,
3415
+ term.notes ?? null
3416
+ );
3417
+ }
3418
+ }
3419
+
3420
+ private async replaceContractRevisions(
3421
+ client: any,
3422
+ contractId: number,
3423
+ revisions?: ContractPayload['revisions']
3424
+ ) {
3425
+ await client.$executeRawUnsafe(
3426
+ `UPDATE operations_contract_revision
3427
+ SET deleted_at = NOW(),
3428
+ updated_at = NOW()
3429
+ WHERE contract_id = $1
3430
+ AND deleted_at IS NULL`,
3431
+ contractId
3432
+ );
3433
+
3434
+ for (const revision of revisions ?? []) {
3435
+ await client.$executeRawUnsafe(
3436
+ `INSERT INTO operations_contract_revision (
3437
+ contract_id,
3438
+ revision_type,
3439
+ title,
3440
+ effective_date,
3441
+ status,
3442
+ summary,
3443
+ created_at,
3444
+ updated_at
3445
+ ) VALUES (
3446
+ $1, COALESCE($2, 'revision'), $3, $4, COALESCE($5, 'draft'), $6, NOW(), NOW()
3447
+ )`,
3448
+ contractId,
3449
+ revision.revisionType ?? 'revision',
3450
+ revision.title,
3451
+ revision.effectiveDate ?? null,
3452
+ revision.status ?? 'draft',
3453
+ revision.summary ?? null
3454
+ );
3455
+ }
3456
+ }
3457
+
3458
+ private async replaceContractPdfDocument(
3459
+ client: any,
3460
+ contractId: number,
3461
+ document: NonNullable<ContractPayload['replaceUploadedPdfDocument']>
3462
+ ) {
3463
+ await client.$executeRawUnsafe(
3464
+ `UPDATE operations_contract_document
3465
+ SET is_current = false,
3466
+ updated_at = NOW()
3467
+ WHERE contract_id = $1
3468
+ AND deleted_at IS NULL
3469
+ AND document_type IN ('uploaded_pdf', 'generated_pdf')`,
3470
+ contractId
3471
+ );
3472
+
3473
+ await client.$executeRawUnsafe(
3474
+ `INSERT INTO operations_contract_document (
3475
+ contract_id,
3476
+ document_type,
3477
+ file_name,
3478
+ mime_type,
3479
+ file_content_base64,
3480
+ is_current,
3481
+ notes,
3482
+ created_at,
3483
+ updated_at
3484
+ ) VALUES (
3485
+ $1, 'uploaded_pdf', $2, $3, $4, true, $5, NOW(), NOW()
3486
+ )`,
3487
+ contractId,
3488
+ document.fileName,
3489
+ document.mimeType,
3490
+ document.fileContentBase64,
3491
+ document.notes ?? null
3492
+ );
3493
+ }
3494
+
3495
+ private async insertContractHistory(
3496
+ client: any,
3497
+ contractId: number,
3498
+ actorUserId: number | null,
3499
+ action: string,
3500
+ note: string | null,
3501
+ metadataJson?: string | null
3502
+ ) {
3503
+ await client.$executeRawUnsafe(
3504
+ `INSERT INTO operations_contract_history (
3505
+ contract_id,
3506
+ actor_user_id,
3507
+ action,
3508
+ note,
3509
+ metadata_json,
3510
+ created_at
3511
+ ) VALUES (
3512
+ $1, $2, $3, $4, $5, NOW()
3513
+ )`,
3514
+ contractId,
3515
+ actorUserId,
3516
+ action,
3517
+ note,
3518
+ metadataJson ?? null
3519
+ );
3520
+ }
3521
+
3522
+ private requireFields(input: Record<string, unknown>, required: string[]) {
3523
+ for (const field of required) {
3524
+ const value = input[field];
3525
+ if (value === undefined || value === null || value === '') {
3526
+ throw new BadRequestException(`Field "${field}" is required.`);
3527
+ }
3528
+ }
3529
+ }
3530
+
3531
+ private buildIdFilter(ids: number[], column: string, allowAll: boolean) {
3532
+ if (allowAll) {
3533
+ return { clause: '1 = 1', params: [] as unknown[] };
3534
+ }
3535
+ if (!ids.length) {
3536
+ return { clause: '1 = 0', params: [] as unknown[] };
3537
+ }
3538
+ return {
3539
+ clause: `${column} = ANY($1::int[])`,
3540
+ params: [ids] as unknown[],
3541
+ };
3542
+ }
3543
+
3544
+ private uniqueNumbers(values: Array<number | null | undefined>) {
3545
+ return [
3546
+ ...new Set(
3547
+ values.filter((value): value is number => typeof value === 'number')
3548
+ ),
3549
+ ];
3550
+ }
3551
+
3552
+ private pushUpdate(
3553
+ updates: string[],
3554
+ params: unknown[],
3555
+ column: string,
3556
+ value: unknown
3557
+ ) {
3558
+ if (value === undefined) return;
3559
+ params.push(value);
3560
+ updates.push(`${column} = $${params.length}`);
3561
+ }
3562
+
3563
+ private param(params: unknown[], value: unknown) {
3564
+ params.push(value);
3565
+ return `$${params.length}`;
3566
+ }
3567
+
3568
+ private groupBy<T extends Record<string, any>>(items: T[], key: keyof T) {
3569
+ return items.reduce<Record<string, T[]>>((accumulator, item) => {
3570
+ const groupKey = String(item[key]);
3571
+ accumulator[groupKey] ??= [];
3572
+ accumulator[groupKey].push(item);
3573
+ return accumulator;
3574
+ }, {});
3575
+ }
3576
+
3577
+ private async queryRows<T = Record<string, unknown>>(
3578
+ sql: string,
3579
+ params: unknown[] = []
3580
+ ) {
3581
+ return this.prisma.$queryRawUnsafe<T[]>(sql, ...params);
3582
+ }
3583
+
3584
+ private async querySingle<T = Record<string, unknown>>(
3585
+ sql: string,
3586
+ params: unknown[] = []
3587
+ ) {
3588
+ const rows = await this.queryRows<T>(sql, params);
3589
+ return rows[0] ?? null;
3590
+ }
3591
+
3592
+ private async execute(sql: string, params: unknown[] = []) {
3593
+ return this.prisma.$executeRawUnsafe(sql, ...params);
3594
+ }
3595
+ }