@hed-hog/operations 0.0.300 → 0.0.302

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 (73) hide show
  1. package/dist/operations.controller.d.ts +713 -31
  2. package/dist/operations.controller.d.ts.map +1 -1
  3. package/dist/operations.controller.js +157 -0
  4. package/dist/operations.controller.js.map +1 -1
  5. package/dist/operations.module.d.ts.map +1 -1
  6. package/dist/operations.module.js +5 -1
  7. package/dist/operations.module.js.map +1 -1
  8. package/dist/operations.proposal.subscriber.d.ts +11 -0
  9. package/dist/operations.proposal.subscriber.d.ts.map +1 -0
  10. package/dist/operations.proposal.subscriber.js +80 -0
  11. package/dist/operations.proposal.subscriber.js.map +1 -0
  12. package/dist/operations.proposal.subscriber.spec.d.ts +2 -0
  13. package/dist/operations.proposal.subscriber.spec.d.ts.map +1 -0
  14. package/dist/operations.proposal.subscriber.spec.js +88 -0
  15. package/dist/operations.proposal.subscriber.spec.js.map +1 -0
  16. package/dist/operations.service.d.ts +491 -46
  17. package/dist/operations.service.d.ts.map +1 -1
  18. package/dist/operations.service.js +2484 -121
  19. package/dist/operations.service.js.map +1 -1
  20. package/dist/operations.service.spec.d.ts +2 -0
  21. package/dist/operations.service.spec.d.ts.map +1 -0
  22. package/dist/operations.service.spec.js +159 -0
  23. package/dist/operations.service.spec.js.map +1 -0
  24. package/hedhog/data/menu.yaml +35 -22
  25. package/hedhog/data/role_route.yaml +39 -0
  26. package/hedhog/data/route.yaml +130 -0
  27. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +8 -6
  28. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +1163 -327
  29. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -0
  30. package/hedhog/frontend/app/_components/contract-content-editor.tsx.ejs +258 -0
  31. package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +631 -0
  32. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +353 -27
  33. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +1926 -87
  34. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +526 -0
  35. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -0
  36. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -0
  37. package/hedhog/frontend/app/_components/department-select-with-create.tsx.ejs +370 -0
  38. package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +826 -0
  39. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +1251 -364
  40. package/hedhog/frontend/app/_components/section-card.tsx.ejs +48 -13
  41. package/hedhog/frontend/app/_lib/api.ts.ejs +2 -5
  42. package/hedhog/frontend/app/_lib/types.ts.ejs +76 -33
  43. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +85 -8
  44. package/hedhog/frontend/app/approvals/page.tsx.ejs +90 -54
  45. package/hedhog/frontend/app/collaborators/[id]/edit/page.tsx.ejs +2 -2
  46. package/hedhog/frontend/app/collaborators/[id]/page.tsx.ejs +2 -2
  47. package/hedhog/frontend/app/collaborators/page.tsx.ejs +597 -140
  48. package/hedhog/frontend/app/contracts/[id]/edit/page.tsx.ejs +2 -2
  49. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
  50. package/hedhog/frontend/app/contracts/page.tsx.ejs +941 -262
  51. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +384 -0
  52. package/hedhog/frontend/app/departments/page.tsx.ejs +442 -0
  53. package/hedhog/frontend/app/page.tsx.ejs +3 -317
  54. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +2 -2
  55. package/hedhog/frontend/app/projects/new/page.tsx.ejs +2 -2
  56. package/hedhog/frontend/app/projects/page.tsx.ejs +264 -102
  57. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +50 -28
  58. package/hedhog/frontend/app/time-off/page.tsx.ejs +57 -31
  59. package/hedhog/frontend/app/timesheets/page.tsx.ejs +85 -42
  60. package/hedhog/frontend/messages/en.json +473 -12
  61. package/hedhog/frontend/messages/pt.json +528 -66
  62. package/hedhog/table/operations_collaborator.yaml +20 -0
  63. package/hedhog/table/operations_contract.yaml +22 -1
  64. package/hedhog/table/operations_contract_document.yaml +33 -16
  65. package/hedhog/table/operations_contract_template.yaml +58 -0
  66. package/hedhog/table/operations_department.yaml +24 -0
  67. package/package.json +7 -5
  68. package/src/operations.controller.ts +122 -0
  69. package/src/operations.module.ts +6 -2
  70. package/src/operations.proposal.subscriber.spec.ts +121 -0
  71. package/src/operations.proposal.subscriber.ts +86 -0
  72. package/src/operations.service.spec.ts +210 -0
  73. package/src/operations.service.ts +4026 -241
@@ -1,15 +1,109 @@
1
1
  import { PrismaService } from '@hed-hog/api-prisma';
2
- import { IntegrationDeveloperApiService } from '@hed-hog/core';
2
+ import {
3
+ AiService,
4
+ FileService,
5
+ IntegrationDeveloperApiService,
6
+ SettingService,
7
+ } from '@hed-hog/core';
3
8
  import {
4
9
  BadRequestException,
5
10
  ForbiddenException,
11
+ Inject,
6
12
  Injectable,
13
+ InternalServerErrorException,
14
+ Logger,
7
15
  NotFoundException,
16
+ forwardRef,
8
17
  } from '@nestjs/common';
9
18
 
10
19
  const COLLABORATOR_ROLE = 'admin-operations-collaborator';
11
20
  const SUPERVISOR_ROLE = 'admin-operations-supervisor';
12
21
  const DIRECTOR_ROLE = 'admin-operations-director';
22
+ const CONTRACT_CATEGORY_VALUES = [
23
+ 'employee',
24
+ 'contractor',
25
+ 'client',
26
+ 'supplier',
27
+ 'vendor',
28
+ 'partner',
29
+ 'internal',
30
+ 'other',
31
+ ] as const;
32
+ const CONTRACT_TYPE_VALUES = [
33
+ 'clt',
34
+ 'pj',
35
+ 'freelancer_agreement',
36
+ 'service_agreement',
37
+ 'fixed_term',
38
+ 'recurring_service',
39
+ 'nda',
40
+ 'amendment',
41
+ 'addendum',
42
+ 'other',
43
+ ] as const;
44
+ const BILLING_MODEL_VALUES = [
45
+ 'time_and_material',
46
+ 'monthly_retainer',
47
+ 'fixed_price',
48
+ ] as const;
49
+ const SIGNATURE_STATUS_VALUES = [
50
+ 'not_started',
51
+ 'pending',
52
+ 'partially_signed',
53
+ 'signed',
54
+ 'expired',
55
+ ] as const;
56
+ const CONTRACT_STATUS_VALUES = [
57
+ 'draft',
58
+ 'under_review',
59
+ 'active',
60
+ 'renewal',
61
+ 'expired',
62
+ 'closed',
63
+ 'archived',
64
+ ] as const;
65
+ const CONTRACT_CREATION_MODE_VALUES = [
66
+ 'blank',
67
+ 'template',
68
+ 'upload',
69
+ 'duplicate',
70
+ ] as const;
71
+ const ORIGIN_TYPE_VALUES = [
72
+ 'manual',
73
+ 'employee_hiring',
74
+ 'client_project',
75
+ 'crm_proposal',
76
+ ] as const;
77
+ const CONTRACT_DOCUMENT_TYPE_VALUES = [
78
+ 'source_upload',
79
+ 'generated_pdf',
80
+ 'attachment',
81
+ 'other',
82
+ ] as const;
83
+ const CONTRACT_DOCUMENT_EXTRACTION_STATUS_VALUES = [
84
+ 'pending',
85
+ 'processing',
86
+ 'completed',
87
+ 'failed',
88
+ 'skipped',
89
+ ] as const;
90
+ const PARTY_ROLE_VALUES = [
91
+ 'employee',
92
+ 'employer',
93
+ 'client',
94
+ 'supplier',
95
+ 'vendor',
96
+ 'partner',
97
+ 'witness',
98
+ 'internal_owner',
99
+ 'other',
100
+ ] as const;
101
+ const PARTY_TYPE_VALUES = ['individual', 'company', 'internal_team', 'other'] as const;
102
+ const SIGNATURE_ITEM_STATUS_VALUES = ['pending', 'signed', 'rejected'] as const;
103
+ const FINANCIAL_TERM_TYPE_VALUES = ['value', 'payment', 'revenue', 'fine', 'other'] as const;
104
+ const RECURRENCE_VALUES = ['one_time', 'monthly', 'quarterly', 'yearly', 'other'] as const;
105
+ const REVISION_TYPE_VALUES = ['amendment', 'renewal', 'revision', 'addendum', 'other'] as const;
106
+ const REVISION_STATUS_VALUES = ['draft', 'active', 'completed', 'cancelled'] as const;
13
107
 
14
108
  type ApprovalAction = 'approve' | 'reject';
15
109
  type ApprovalTargetType =
@@ -31,11 +125,13 @@ type ActorContext = {
31
125
  };
32
126
 
33
127
  type CollaboratorPayload = {
34
- userId: number;
35
- code: string;
36
- displayName: string;
128
+ userId?: number | null;
129
+ personId?: number | null;
130
+ code?: string | null;
131
+ displayName?: string | null;
37
132
  collaboratorType?: 'clt' | 'pj' | 'freelancer' | 'intern' | 'other';
38
133
  department?: string | null;
134
+ departmentId?: number | null;
39
135
  title?: string | null;
40
136
  levelLabel?: string | null;
41
137
  supervisorCollaboratorId?: number | null;
@@ -63,10 +159,17 @@ type CollaboratorPayload = {
63
159
  notes?: string | null;
64
160
  };
65
161
 
162
+ type DepartmentPayload = {
163
+ code?: string | null;
164
+ name?: string | null;
165
+ description?: string | null;
166
+ status?: 'active' | 'inactive';
167
+ };
168
+
66
169
  type ContractPayload = {
67
- code: string;
68
- name: string;
69
- clientName: string;
170
+ code?: string | null;
171
+ name?: string | null;
172
+ clientName?: string | null;
70
173
  contractCategory?:
71
174
  | 'employee'
72
175
  | 'contractor'
@@ -100,9 +203,10 @@ type ContractPayload = {
100
203
  isActive?: boolean;
101
204
  accountManagerCollaboratorId?: number | null;
102
205
  relatedCollaboratorId?: number | null;
103
- originType?: 'manual' | 'employee_hiring' | 'client_project';
206
+ contractTemplateId?: number | null;
207
+ originType?: 'manual' | 'employee_hiring' | 'client_project' | 'crm_proposal';
104
208
  originId?: number | null;
105
- startDate: string;
209
+ startDate?: string | null;
106
210
  endDate?: string | null;
107
211
  signedAt?: string | null;
108
212
  effectiveDate?: string | null;
@@ -116,6 +220,8 @@ type ContractPayload = {
116
220
  | 'expired'
117
221
  | 'closed'
118
222
  | 'archived';
223
+ creationMode?: 'blank' | 'template' | 'upload' | 'duplicate';
224
+ wizardStep?: number | null;
119
225
  description?: string | null;
120
226
  contentHtml?: string | null;
121
227
  parties?: Array<{
@@ -159,15 +265,248 @@ type ContractPayload = {
159
265
  summary?: string | null;
160
266
  }>;
161
267
  replaceUploadedPdfDocument?: {
268
+ fileId?: number | null;
162
269
  fileName: string;
163
270
  mimeType: string;
164
- fileContentBase64: string;
271
+ fileContentBase64?: string | null;
272
+ notes?: string | null;
273
+ extractionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'skipped';
274
+ extractionSummary?: string | null;
275
+ } | null;
276
+ };
277
+
278
+ type ContractDraftPayload = {
279
+ creationMode?: 'blank' | 'template' | 'upload' | 'duplicate' | null;
280
+ templateId?: number | null;
281
+ duplicateFromId?: number | null;
282
+ sourceFileId?: number | null;
283
+ sourceFileName?: string | null;
284
+ sourceMimeType?: string | null;
285
+ };
286
+
287
+ type ProposalApprovedEventPayload = {
288
+ proposalId?: number;
289
+ proposalRevisionId?: number | null;
290
+ personId?: number | null;
291
+ approvedByUserId?: number | null;
292
+ approvedAt?: string | null;
293
+ correlationId?: string | null;
294
+ sourceModule?: string | null;
295
+ sourceEntity?: string | null;
296
+ sourceId?: string | null;
297
+ source_module?: string | null;
298
+ source_entity?: string | null;
299
+ source_id?: string | null;
300
+ code?: string | null;
301
+ title?: string | null;
302
+ version?: number | null;
303
+ total?: number | null;
304
+ currency?: string | null;
305
+ locale?: string | null;
306
+ validFrom?: string | null;
307
+ validUntil?: string | null;
308
+ commercialTerms?: {
309
+ contractCategory?: ContractPayload['contractCategory'];
310
+ contractType?: ContractPayload['contractType'];
311
+ billingModel?: ContractPayload['billingModel'];
312
+ validFrom?: string | null;
313
+ validUntil?: string | null;
314
+ notes?: string | null;
315
+ } | null;
316
+ proposal?: {
317
+ code?: string | null;
318
+ title?: string | null;
319
+ contractCategory?: ContractPayload['contractCategory'];
320
+ contractType?: ContractPayload['contractType'];
321
+ billingModel?: ContractPayload['billingModel'];
322
+ validFrom?: string | null;
323
+ validUntil?: string | null;
324
+ totalAmount?: number | null;
325
+ totalAmountCents?: number | null;
165
326
  notes?: string | null;
327
+ };
328
+ person?: {
329
+ id?: number | null;
330
+ name?: string | null;
331
+ tradeName?: string | null;
332
+ email?: string | null;
333
+ phone?: string | null;
334
+ document?: string | null;
335
+ } | null;
336
+ revision?: {
337
+ id?: number | null;
338
+ revisionNumber?: number | null;
339
+ title?: string | null;
340
+ summary?: string | null;
341
+ contentHtml?: string | null;
166
342
  } | null;
343
+ items?: Array<{
344
+ name?: string | null;
345
+ description?: string | null;
346
+ termType?: ContractPayload['financialTerms'][number]['termType'];
347
+ recurrence?: ContractPayload['financialTerms'][number]['recurrence'];
348
+ dueDay?: number | null;
349
+ amount?: number | null;
350
+ totalAmountCents?: number | null;
351
+ }>;
352
+ };
353
+
354
+ type ContractTemplatePayload = {
355
+ code?: string | null;
356
+ name?: string | null;
357
+ description?: string | null;
358
+ contractCategory?:
359
+ | 'employee'
360
+ | 'contractor'
361
+ | 'client'
362
+ | 'supplier'
363
+ | 'vendor'
364
+ | 'partner'
365
+ | 'internal'
366
+ | 'other';
367
+ contractType?:
368
+ | 'clt'
369
+ | 'pj'
370
+ | 'freelancer_agreement'
371
+ | 'service_agreement'
372
+ | 'fixed_term'
373
+ | 'recurring_service'
374
+ | 'nda'
375
+ | 'amendment'
376
+ | 'addendum'
377
+ | 'other';
378
+ billingModel?:
379
+ | 'time_and_material'
380
+ | 'monthly_retainer'
381
+ | 'fixed_price';
382
+ signatureStatus?:
383
+ | 'not_started'
384
+ | 'pending'
385
+ | 'partially_signed'
386
+ | 'signed'
387
+ | 'expired';
388
+ isActive?: boolean;
389
+ status?: 'draft' | 'active' | 'inactive' | 'archived';
390
+ contentHtml?: string | null;
391
+ };
392
+
393
+ type ContractExtractDraftPayload = {
394
+ contractId?: number | null;
395
+ fileName?: string | null;
396
+ mimeType?: string | null;
397
+ fileContentBase64?: string | null;
398
+ provider?: 'openai' | 'gemini' | null;
399
+ promptMessage?: string | null;
400
+ };
401
+
402
+ type ContractGenerateContentPayload = {
403
+ provider?: 'openai' | 'gemini' | null;
404
+ promptMessage?: string | null;
405
+ overwrite?: boolean;
406
+ };
407
+
408
+ type ContractLegalReviewPayload = {
409
+ provider?: 'openai' | 'gemini' | null;
410
+ promptMessage?: string | null;
411
+ };
412
+
413
+ type ContractLegalReviewResult = {
414
+ summary: string;
415
+ missingFields: string[];
416
+ warnings: string[];
417
+ checklist: string[];
418
+ status: 'ready_for_revision' | 'attention_required';
419
+ reviewedAt: string;
420
+ };
421
+
422
+ type ContractProjectSummary = {
423
+ id: number;
424
+ code: string;
425
+ name: string;
426
+ status: string;
427
+ };
428
+
429
+ type ContractScheduleDay = {
430
+ weekday: string;
431
+ isWorkingDay: boolean;
432
+ startTime: string | null;
433
+ endTime: string | null;
434
+ breakMinutes: number | null;
435
+ };
436
+
437
+ type ContractDocumentRecord = {
438
+ id: number;
439
+ documentType: (typeof CONTRACT_DOCUMENT_TYPE_VALUES)[number];
440
+ fileId: number | null;
441
+ fileName: string;
442
+ mimeType: string;
443
+ fileContentBase64: string | null;
444
+ isCurrent: boolean;
445
+ extractionStatus:
446
+ | (typeof CONTRACT_DOCUMENT_EXTRACTION_STATUS_VALUES)[number]
447
+ | null;
448
+ extractionSummary: string | null;
449
+ notes: string | null;
450
+ createdAt: string;
451
+ };
452
+
453
+ type ContractHistoryRecord = {
454
+ id: number;
455
+ actorUserId: number | null;
456
+ action: string;
457
+ note: string | null;
458
+ metadataJson: string | null;
459
+ createdAt: string;
460
+ };
461
+
462
+ type ContractDetailRecord = {
463
+ id: number;
464
+ code: string;
465
+ name: string | null;
466
+ contractCategory: (typeof CONTRACT_CATEGORY_VALUES)[number];
467
+ contractType: (typeof CONTRACT_TYPE_VALUES)[number];
468
+ clientName: string | null;
469
+ signatureStatus: (typeof SIGNATURE_STATUS_VALUES)[number];
470
+ isActive: boolean;
471
+ billingModel: (typeof BILLING_MODEL_VALUES)[number];
472
+ accountManagerCollaboratorId: number | null;
473
+ relatedCollaboratorId: number | null;
474
+ contractTemplateId: number | null;
475
+ contractTemplateName: string | null;
476
+ contractTemplateSlug: string | null;
477
+ contractTemplateCode: string | null;
478
+ originType: (typeof ORIGIN_TYPE_VALUES)[number];
479
+ originId: number | null;
480
+ startDate: string | null;
481
+ endDate: string | null;
482
+ signedAt: string | null;
483
+ effectiveDate: string | null;
484
+ budgetAmount: number | null;
485
+ monthlyHourCap: number | null;
486
+ status: (typeof CONTRACT_STATUS_VALUES)[number];
487
+ creationMode: (typeof CONTRACT_CREATION_MODE_VALUES)[number] | null;
488
+ wizardStep: number | null;
489
+ description: string | null;
490
+ contentHtml: string | null;
491
+ accountManagerName: string | null;
492
+ relatedCollaboratorName: string | null;
493
+ };
494
+
495
+ type ContractDetailsRecord = ContractDetailRecord & {
496
+ mainRelatedPartyName: string | null;
497
+ projects: ContractProjectSummary[];
498
+ scheduleSummary: ContractScheduleDay[];
499
+ parties: NonNullable<ContractPayload['parties']>;
500
+ signatures: NonNullable<ContractPayload['signatures']>;
501
+ financialTerms: NonNullable<ContractPayload['financialTerms']>;
502
+ documents: ContractDocumentRecord[];
503
+ revisions: NonNullable<ContractPayload['revisions']>;
504
+ history: ContractHistoryRecord[];
167
505
  };
168
506
 
169
507
  type ProjectPayload = {
170
508
  contractId?: number | null;
509
+ contractTemplateId?: number | null;
171
510
  managerCollaboratorId?: number | null;
172
511
  code: string;
173
512
  name: string;
@@ -284,9 +623,16 @@ type PublishAccountsPayableReferencePayload = {
284
623
 
285
624
  @Injectable()
286
625
  export class OperationsService {
626
+ private readonly logger = new Logger(OperationsService.name);
627
+
287
628
  constructor(
288
629
  private readonly prisma: PrismaService,
630
+ private readonly aiService: AiService,
289
631
  private readonly integrationApi: IntegrationDeveloperApiService,
632
+ @Inject(forwardRef(() => FileService))
633
+ private readonly fileService: FileService,
634
+ @Inject(forwardRef(() => SettingService))
635
+ private readonly settingService: SettingService,
290
636
  ) {}
291
637
 
292
638
  async getDashboard(userId: number) {
@@ -416,10 +762,14 @@ export class OperationsService {
416
762
  return this.queryRows(
417
763
  `SELECT c.id,
418
764
  c.user_id AS "userId",
765
+ c.person_id AS "personId",
419
766
  c.code,
420
767
  c.collaborator_type AS "collaboratorType",
421
- c.display_name AS "displayName",
422
- c.department,
768
+ COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
769
+ person_record.name AS "personName",
770
+ person_record.avatar_id AS "personAvatarId",
771
+ c.department_id AS "departmentId",
772
+ COALESCE(NULLIF(department_record.name, ''), NULLIF(c.department, '')) AS "department",
423
773
  c.title,
424
774
  c.level_label AS "levelLabel",
425
775
  c.weekly_capacity_hours AS "weeklyCapacityHours",
@@ -433,6 +783,11 @@ export class OperationsService {
433
783
  hiring_contract.status AS "contractStatus",
434
784
  COUNT(DISTINCT pa.id)::int AS "activeAssignments"
435
785
  FROM operations_collaborator c
786
+ LEFT JOIN person person_record
787
+ ON person_record.id = c.person_id
788
+ LEFT JOIN operations_department department_record
789
+ ON department_record.id = c.department_id
790
+ AND department_record.deleted_at IS NULL
436
791
  LEFT JOIN operations_collaborator s
437
792
  ON s.id = c.supervisor_collaborator_id
438
793
  LEFT JOIN operations_project_assignment pa
@@ -450,8 +805,8 @@ export class OperationsService {
450
805
  ) hiring_contract ON TRUE
451
806
  WHERE c.deleted_at IS NULL
452
807
  AND ${filter.clause}
453
- GROUP BY c.id, s.id, hiring_contract.id, hiring_contract.status
454
- ORDER BY c.display_name ASC`,
808
+ GROUP BY c.id, person_record.id, department_record.id, s.id, hiring_contract.id, hiring_contract.status
809
+ ORDER BY COALESCE(NULLIF(c.display_name, ''), person_record.name) ASC`,
455
810
  filter.params
456
811
  );
457
812
  }
@@ -494,10 +849,14 @@ export class OperationsService {
494
849
  const teamMembers = await this.queryRows(
495
850
  `SELECT c.id,
496
851
  c.user_id AS "userId",
852
+ c.person_id AS "personId",
497
853
  c.code,
498
- c.display_name AS "displayName",
854
+ COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
855
+ person_record.name AS "personName",
856
+ person_record.avatar_id AS "personAvatarId",
499
857
  c.collaborator_type AS "collaboratorType",
500
- c.department,
858
+ c.department_id AS "departmentId",
859
+ COALESCE(NULLIF(department_record.name, ''), NULLIF(c.department, '')) AS "department",
501
860
  c.title,
502
861
  c.status,
503
862
  COUNT(DISTINCT pa.id)::int AS "activeAssignments",
@@ -505,6 +864,11 @@ export class OperationsService {
505
864
  COUNT(DISTINCT tor.id) FILTER (WHERE tor.status = 'submitted')::int AS "pendingTimeOffRequests",
506
865
  COUNT(DISTINCT sar.id) FILTER (WHERE sar.status = 'submitted')::int AS "pendingScheduleAdjustmentRequests"
507
866
  FROM operations_collaborator c
867
+ LEFT JOIN person person_record
868
+ ON person_record.id = c.person_id
869
+ LEFT JOIN operations_department department_record
870
+ ON department_record.id = c.department_id
871
+ AND department_record.deleted_at IS NULL
508
872
  LEFT JOIN operations_project_assignment pa
509
873
  ON pa.collaborator_id = c.id
510
874
  AND pa.deleted_at IS NULL
@@ -522,8 +886,8 @@ export class OperationsService {
522
886
  AND sar.deleted_at IS NULL
523
887
  AND sar.status = 'submitted'
524
888
  WHERE c.deleted_at IS NULL AND ${teamFilter.clause}
525
- GROUP BY c.id
526
- ORDER BY c.display_name ASC`,
889
+ GROUP BY c.id, person_record.id, department_record.id
890
+ ORDER BY COALESCE(NULLIF(c.display_name, ''), person_record.name) ASC`,
527
891
  teamFilter.params
528
892
  );
529
893
 
@@ -694,17 +1058,40 @@ export class OperationsService {
694
1058
  async createCollaborator(userId: number, data: CollaboratorPayload) {
695
1059
  const actor = await this.getActorContext(userId);
696
1060
  this.ensureDirector(actor);
697
- this.requireFields(data, ['userId', 'code', 'displayName']);
1061
+
1062
+ const resolvedPerson = data.personId
1063
+ ? await this.getPersonById(Number(data.personId))
1064
+ : null;
1065
+ const resolvedDisplayName =
1066
+ resolvedPerson?.name?.trim() ?? data.displayName?.trim() ?? '';
1067
+
1068
+ if (!resolvedDisplayName) {
1069
+ throw new BadRequestException('Field "personId" is required.');
1070
+ }
698
1071
 
699
1072
  const collaboratorId = await this.prisma.$transaction(async (tx) => {
1073
+ const normalizedCode =
1074
+ String(data.code ?? '').trim() ||
1075
+ (await this.generateCollaboratorCode(tx as any));
1076
+
1077
+ const resolvedDepartment = await this.resolveDepartmentReference(
1078
+ tx as any,
1079
+ {
1080
+ departmentId: data.departmentId ?? null,
1081
+ departmentName: data.department,
1082
+ }
1083
+ );
1084
+
700
1085
  const created = (await (tx as any).$queryRawUnsafe(
701
1086
  `INSERT INTO operations_collaborator (
702
1087
  user_id,
1088
+ person_id,
703
1089
  supervisor_collaborator_id,
704
1090
  code,
705
1091
  collaborator_type,
706
1092
  display_name,
707
1093
  department,
1094
+ department_id,
708
1095
  title,
709
1096
  level_label,
710
1097
  weekly_capacity_hours,
@@ -715,16 +1102,21 @@ export class OperationsService {
715
1102
  created_at,
716
1103
  updated_at
717
1104
  ) VALUES (
718
- $1, $2, $3, COALESCE($4, 'other'), $5, $6, $7, $8, $9,
719
- COALESCE($10, 'active'), $11, $12, $13, NOW(), NOW()
1105
+ $1, $2, $3, $4,
1106
+ $5::operations_collaborator_collaborator_type_7dd7b0ada2_enum,
1107
+ $6, $7, $8, $9, $10, $11,
1108
+ $12::operations_collaborator_status_ef779877d4_enum,
1109
+ $13::date, $14::date, $15, NOW(), NOW()
720
1110
  )
721
1111
  RETURNING id`,
722
- data.userId,
1112
+ data.userId ?? null,
1113
+ resolvedPerson?.id ?? null,
723
1114
  data.supervisorCollaboratorId ?? null,
724
- data.code,
1115
+ normalizedCode,
725
1116
  data.collaboratorType ?? 'other',
726
- data.displayName,
727
- data.department ?? null,
1117
+ resolvedDisplayName,
1118
+ resolvedDepartment?.name ?? null,
1119
+ resolvedDepartment?.id ?? null,
728
1120
  data.title ?? null,
729
1121
  data.levelLabel ?? null,
730
1122
  data.weeklyCapacityHours ?? null,
@@ -744,8 +1136,8 @@ export class OperationsService {
744
1136
  if (data.autoGenerateContractDraft !== false) {
745
1137
  await this.createHiringContractDraft(tx as any, actor.userId, {
746
1138
  collaboratorId: createdCollaboratorId,
747
- collaboratorCode: data.code,
748
- displayName: data.displayName,
1139
+ collaboratorCode: normalizedCode,
1140
+ displayName: resolvedDisplayName,
749
1141
  collaboratorType: data.collaboratorType ?? 'other',
750
1142
  supervisorCollaboratorId: data.supervisorCollaboratorId ?? null,
751
1143
  startDate: data.joinedAt ?? null,
@@ -772,20 +1164,65 @@ export class OperationsService {
772
1164
 
773
1165
  const updates: string[] = [];
774
1166
  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);
1167
+ const resolvedPerson =
1168
+ data.personId !== undefined && data.personId !== null
1169
+ ? await this.getPersonById(Number(data.personId))
1170
+ : null;
1171
+ const resolvedDisplayName =
1172
+ resolvedPerson?.name?.trim() ?? data.displayName?.trim() ?? undefined;
1173
+
1174
+ this.pushUpdate(updates, params, 'user_id', data.userId);
1175
+ this.pushUpdate(updates, params, 'person_id', data.personId);
1176
+ this.pushUpdate(
1177
+ updates,
1178
+ params,
1179
+ 'supervisor_collaborator_id',
1180
+ data.supervisorCollaboratorId
1181
+ );
1182
+ this.pushUpdate(updates, params, 'code', data.code?.trim());
1183
+ this.pushUpdate(
1184
+ updates,
1185
+ params,
1186
+ 'collaborator_type',
1187
+ data.collaboratorType,
1188
+ 'operations_collaborator_collaborator_type_7dd7b0ada2_enum'
1189
+ );
1190
+ if (data.personId !== undefined || data.displayName !== undefined) {
1191
+ this.pushUpdate(updates, params, 'display_name', resolvedDisplayName ?? null);
1192
+ }
780
1193
  this.pushUpdate(updates, params, 'title', data.title);
781
1194
  this.pushUpdate(updates, params, 'level_label', data.levelLabel);
782
1195
  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);
1196
+ this.pushUpdate(
1197
+ updates,
1198
+ params,
1199
+ 'status',
1200
+ data.status,
1201
+ 'operations_collaborator_status_ef779877d4_enum'
1202
+ );
1203
+ this.pushUpdate(updates, params, 'joined_at', data.joinedAt, 'date');
1204
+ this.pushUpdate(updates, params, 'left_at', data.leftAt, 'date');
786
1205
  this.pushUpdate(updates, params, 'notes', data.notes);
787
1206
 
788
1207
  await this.prisma.$transaction(async (tx) => {
1208
+ if (data.department !== undefined || data.departmentId !== undefined) {
1209
+ const resolvedDepartment = await this.resolveDepartmentReference(
1210
+ tx as any,
1211
+ {
1212
+ departmentId: data.departmentId ?? null,
1213
+ departmentName: data.department,
1214
+ }
1215
+ );
1216
+
1217
+ this.pushUpdate(updates, params, 'department', resolvedDepartment?.name ?? null);
1218
+ this.pushUpdate(
1219
+ updates,
1220
+ params,
1221
+ 'department_id',
1222
+ resolvedDepartment?.id ?? null
1223
+ );
1224
+ }
1225
+
789
1226
  if (updates.length) {
790
1227
  params.push(collaboratorId);
791
1228
  await (tx as any).$executeRawUnsafe(
@@ -809,6 +1246,152 @@ export class OperationsService {
809
1246
  return this.getCollaboratorByIdForUser(userId, collaboratorId);
810
1247
  }
811
1248
 
1249
+ async listDepartments(userId: number) {
1250
+ const actor = await this.getActorContext(userId);
1251
+ this.ensureCollaborator(actor);
1252
+
1253
+ return this.queryRows(
1254
+ `SELECT d.id,
1255
+ d.slug,
1256
+ d.code,
1257
+ d.name,
1258
+ d.description,
1259
+ CASE WHEN d.deleted_at IS NULL THEN 'active' ELSE 'inactive' END AS status,
1260
+ COUNT(DISTINCT c.id)::int AS "collaboratorCount",
1261
+ d.created_at AS "createdAt",
1262
+ d.updated_at AS "updatedAt"
1263
+ FROM operations_department d
1264
+ LEFT JOIN operations_collaborator c
1265
+ ON c.deleted_at IS NULL
1266
+ AND (
1267
+ c.department_id = d.id
1268
+ OR (
1269
+ c.department_id IS NULL
1270
+ AND LOWER(COALESCE(c.department, '')) = LOWER(d.name)
1271
+ )
1272
+ )
1273
+ GROUP BY d.id
1274
+ ORDER BY CASE WHEN d.deleted_at IS NULL THEN 0 ELSE 1 END ASC,
1275
+ d.name ASC`
1276
+ );
1277
+ }
1278
+
1279
+ async createDepartment(userId: number, data: DepartmentPayload) {
1280
+ const actor = await this.getActorContext(userId);
1281
+ this.ensureDirector(actor);
1282
+
1283
+ const name = this.normalizeOptionalText(data.name);
1284
+ if (!name) {
1285
+ throw new BadRequestException('Department name is required.');
1286
+ }
1287
+
1288
+ return this.prisma.$transaction(async (tx) => {
1289
+ await this.assertDepartmentNameAvailable(tx as any, name);
1290
+
1291
+ const normalizedCode =
1292
+ this.normalizeOptionalText(data.code)?.toUpperCase() ?? null;
1293
+
1294
+ if (normalizedCode) {
1295
+ await this.assertDepartmentCodeAvailable(tx as any, normalizedCode);
1296
+ }
1297
+
1298
+ const created = (await (tx as any).$queryRawUnsafe(
1299
+ `INSERT INTO operations_department (
1300
+ slug,
1301
+ code,
1302
+ name,
1303
+ description,
1304
+ deleted_at,
1305
+ created_at,
1306
+ updated_at
1307
+ ) VALUES (
1308
+ $1, $2, $3, $4,
1309
+ CASE WHEN $5 = 'inactive' THEN NOW() ELSE NULL END,
1310
+ NOW(), NOW()
1311
+ )
1312
+ RETURNING id`,
1313
+ await this.generateUniqueDepartmentSlug(tx as any, name),
1314
+ normalizedCode,
1315
+ name,
1316
+ this.normalizeOptionalText(data.description),
1317
+ data.status ?? 'active'
1318
+ )) as { id: number }[];
1319
+
1320
+ const createdDepartmentId = created[0]?.id;
1321
+ if (!createdDepartmentId) {
1322
+ throw new BadRequestException('Unable to create the department.');
1323
+ }
1324
+
1325
+ return this.getDepartmentById(tx as any, createdDepartmentId, true);
1326
+ });
1327
+ }
1328
+
1329
+ async updateDepartment(
1330
+ userId: number,
1331
+ departmentId: number,
1332
+ data: Partial<DepartmentPayload>
1333
+ ) {
1334
+ const actor = await this.getActorContext(userId);
1335
+ this.ensureDirector(actor);
1336
+
1337
+ return this.prisma.$transaction(async (tx) => {
1338
+ const current = await this.getDepartmentById(tx as any, departmentId, true);
1339
+ const nextName =
1340
+ data.name !== undefined
1341
+ ? this.normalizeOptionalText(data.name)
1342
+ : current.name;
1343
+
1344
+ if (!nextName) {
1345
+ throw new BadRequestException('Department name is required.');
1346
+ }
1347
+
1348
+ if (String(nextName).toLowerCase() !== String(current.name).toLowerCase()) {
1349
+ await this.assertDepartmentNameAvailable(tx as any, nextName, departmentId);
1350
+ }
1351
+
1352
+ const nextCode =
1353
+ data.code !== undefined
1354
+ ? this.normalizeOptionalText(data.code)?.toUpperCase() ?? null
1355
+ : (current.code ?? null);
1356
+
1357
+ if (nextCode) {
1358
+ await this.assertDepartmentCodeAvailable(tx as any, nextCode, departmentId);
1359
+ }
1360
+
1361
+ const nextDescription =
1362
+ data.description !== undefined
1363
+ ? this.normalizeOptionalText(data.description)
1364
+ : (current.description ?? null);
1365
+ const nextStatus = data.status ?? current.status ?? 'active';
1366
+ const nextSlug =
1367
+ String(nextName).toLowerCase() !== String(current.name).toLowerCase()
1368
+ ? await this.generateUniqueDepartmentSlug(tx as any, nextName, departmentId)
1369
+ : current.slug;
1370
+
1371
+ await (tx as any).$executeRawUnsafe(
1372
+ `UPDATE operations_department
1373
+ SET slug = $1,
1374
+ code = $2,
1375
+ name = $3,
1376
+ description = $4,
1377
+ deleted_at = CASE
1378
+ WHEN $5 = 'inactive' THEN COALESCE(deleted_at, NOW())
1379
+ ELSE NULL
1380
+ END,
1381
+ updated_at = NOW()
1382
+ WHERE id = $6`,
1383
+ nextSlug,
1384
+ nextCode,
1385
+ nextName,
1386
+ nextDescription,
1387
+ nextStatus,
1388
+ departmentId
1389
+ );
1390
+
1391
+ return this.getDepartmentById(tx as any, departmentId, true);
1392
+ });
1393
+ }
1394
+
812
1395
  async listProjects(userId: number) {
813
1396
  const actor = await this.getActorContext(userId);
814
1397
  const filter = this.buildIdFilter(actor.visibleProjectIds, 'p.id', actor.isDirector);
@@ -887,8 +1470,11 @@ export class OperationsService {
887
1470
  created_at,
888
1471
  updated_at
889
1472
  ) VALUES (
890
- $1, $2, $3, $4, $5, $6, COALESCE($7, 'planning'), $8,
891
- COALESCE($9, 'project_delivery'), $10, $11, $12, NOW(), NOW()
1473
+ $1, $2, $3, $4, $5, $6,
1474
+ $7::operations_project_status_965e8d4b2d_enum,
1475
+ $8,
1476
+ $9::operations_project_delivery_model_75ee11b3b7_enum,
1477
+ $10, $11::date, $12::date, NOW(), NOW()
892
1478
  )
893
1479
  RETURNING id`,
894
1480
  data.contractId ?? null,
@@ -921,6 +1507,7 @@ export class OperationsService {
921
1507
  actor.userId,
922
1508
  {
923
1509
  projectId,
1510
+ contractTemplateId: data.contractTemplateId ?? null,
924
1511
  projectCode: data.code,
925
1512
  projectName: data.name,
926
1513
  clientName: data.clientName ?? data.name,
@@ -955,7 +1542,7 @@ export class OperationsService {
955
1542
  async updateProject(userId: number, projectId: number, data: Partial<ProjectPayload>) {
956
1543
  const actor = await this.getActorContext(userId);
957
1544
  this.ensureDirector(actor);
958
- await this.getProjectById(userId, projectId);
1545
+ const currentProject = await this.getProjectById(userId, projectId);
959
1546
 
960
1547
  const updates: string[] = [];
961
1548
  const params: unknown[] = [];
@@ -965,12 +1552,24 @@ export class OperationsService {
965
1552
  this.pushUpdate(updates, params, 'name', data.name);
966
1553
  this.pushUpdate(updates, params, 'client_name', data.clientName);
967
1554
  this.pushUpdate(updates, params, 'summary', data.summary);
968
- this.pushUpdate(updates, params, 'status', data.status);
1555
+ this.pushUpdate(
1556
+ updates,
1557
+ params,
1558
+ 'status',
1559
+ data.status,
1560
+ 'operations_project_status_965e8d4b2d_enum'
1561
+ );
969
1562
  this.pushUpdate(updates, params, 'progress_percent', data.progressPercent);
970
- this.pushUpdate(updates, params, 'delivery_model', data.deliveryModel);
1563
+ this.pushUpdate(
1564
+ updates,
1565
+ params,
1566
+ 'delivery_model',
1567
+ data.deliveryModel,
1568
+ 'operations_project_delivery_model_75ee11b3b7_enum'
1569
+ );
971
1570
  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);
1571
+ this.pushUpdate(updates, params, 'start_date', data.startDate, 'date');
1572
+ this.pushUpdate(updates, params, 'end_date', data.endDate, 'date');
974
1573
 
975
1574
  await this.prisma.$transaction(async (tx) => {
976
1575
  if (updates.length) {
@@ -991,102 +1590,374 @@ export class OperationsService {
991
1590
  data.teamAssignments
992
1591
  );
993
1592
  }
1593
+
1594
+ const nextContractId =
1595
+ data.contractId !== undefined
1596
+ ? data.contractId
1597
+ : (currentProject.contractId ?? null);
1598
+ const shouldGenerateDraft =
1599
+ !nextContractId && data.autoGenerateContractDraft === true;
1600
+
1601
+ if (shouldGenerateDraft) {
1602
+ const contractId = await this.createProjectContractDraft(
1603
+ tx as any,
1604
+ actor.userId,
1605
+ {
1606
+ projectId,
1607
+ contractTemplateId: data.contractTemplateId ?? null,
1608
+ projectCode: data.code ?? currentProject.code,
1609
+ projectName: data.name ?? currentProject.name,
1610
+ clientName:
1611
+ data.clientName ??
1612
+ currentProject.clientName ??
1613
+ currentProject.name,
1614
+ managerCollaboratorId:
1615
+ data.managerCollaboratorId ??
1616
+ currentProject.managerCollaboratorId ??
1617
+ null,
1618
+ startDate: data.startDate ?? currentProject.startDate ?? null,
1619
+ endDate: data.endDate ?? currentProject.endDate ?? null,
1620
+ budgetAmount: data.budgetAmount ?? currentProject.budgetAmount ?? null,
1621
+ monthlyHourCap:
1622
+ data.monthlyHourCap ??
1623
+ currentProject.relatedContract?.monthlyHourCap ??
1624
+ null,
1625
+ billingModel:
1626
+ data.billingModel ??
1627
+ currentProject.relatedContract?.billingModel ??
1628
+ 'time_and_material',
1629
+ contractCode:
1630
+ data.contractCode ?? currentProject.relatedContract?.code ?? null,
1631
+ contractName:
1632
+ data.contractName ?? currentProject.relatedContract?.name ?? null,
1633
+ description:
1634
+ data.contractDescription ??
1635
+ currentProject.relatedContract?.description ??
1636
+ data.summary ??
1637
+ currentProject.summary ??
1638
+ null,
1639
+ }
1640
+ );
1641
+
1642
+ await (tx as any).$executeRawUnsafe(
1643
+ `UPDATE operations_project
1644
+ SET contract_id = $1,
1645
+ updated_at = NOW()
1646
+ WHERE id = $2`,
1647
+ contractId,
1648
+ projectId
1649
+ );
1650
+ }
994
1651
  });
995
1652
 
996
1653
  return this.getProjectById(userId, projectId);
997
1654
  }
998
1655
 
999
- async listContracts(userId: number) {
1656
+ async listContractTemplates(userId: number) {
1000
1657
  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
- )`;
1658
+ this.ensureDirector(actor);
1014
1659
 
1015
1660
  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
1661
+ `SELECT t.id,
1662
+ t.slug,
1663
+ t.code,
1664
+ t.name,
1665
+ t.description,
1666
+ t.contract_category AS "contractCategory",
1667
+ t.contract_type AS "contractType",
1668
+ t.billing_model AS "billingModel",
1669
+ t.signature_status AS "signatureStatus",
1670
+ t.is_active AS "isActive",
1671
+ t.status,
1672
+ t.content_html AS "contentHtml",
1673
+ t.created_at AS "createdAt",
1674
+ t.updated_at AS "updatedAt",
1675
+ COUNT(DISTINCT c.id)::int AS "usageCount"
1676
+ FROM operations_contract_template t
1677
+ LEFT JOIN operations_contract c
1678
+ ON c.contract_template_id = t.id
1679
+ AND c.deleted_at IS NULL
1680
+ WHERE t.deleted_at IS NULL
1681
+ GROUP BY t.id
1682
+ ORDER BY CASE
1683
+ WHEN t.status = 'active' THEN 0
1684
+ WHEN t.status = 'draft' THEN 1
1685
+ WHEN t.status = 'inactive' THEN 2
1686
+ ELSE 3
1687
+ END,
1688
+ t.name ASC`
1689
+ );
1690
+ }
1691
+
1692
+ async getContractTemplateById(userId: number, templateId: number) {
1693
+ const actor = await this.getActorContext(userId);
1694
+ this.ensureDirector(actor);
1695
+
1696
+ return this.getContractTemplateRecord(this.prisma as any, templateId);
1697
+ }
1698
+
1699
+ async createContractTemplate(userId: number, data: ContractTemplatePayload) {
1700
+ const actor = await this.getActorContext(userId);
1701
+ this.ensureDirector(actor);
1702
+
1703
+ const name = this.normalizeOptionalText(data.name);
1704
+ if (!name) {
1705
+ throw new BadRequestException('Contract template name is required.');
1706
+ }
1707
+
1708
+ return this.prisma.$transaction(async (tx) => {
1709
+ await this.assertContractTemplateNameAvailable(tx as any, name);
1710
+
1711
+ const nextCode =
1712
+ this.normalizeOptionalText(data.code)?.toUpperCase() ?? null;
1713
+
1714
+ if (nextCode) {
1715
+ await this.assertContractTemplateCodeAvailable(tx as any, nextCode);
1716
+ }
1717
+
1718
+ const nextStatus = data.status ?? 'active';
1719
+ const isActive =
1720
+ data.isActive ?? !['inactive', 'archived'].includes(nextStatus);
1721
+
1722
+ const created = (await (tx as any).$queryRawUnsafe(
1723
+ `INSERT INTO operations_contract_template (
1724
+ slug,
1725
+ code,
1726
+ name,
1727
+ description,
1728
+ contract_category,
1729
+ contract_type,
1730
+ billing_model,
1731
+ signature_status,
1732
+ is_active,
1733
+ status,
1734
+ content_html,
1735
+ created_at,
1736
+ updated_at
1737
+ ) VALUES (
1738
+ $1, $2, $3, $4,
1739
+ $5, $6, $7, $8, $9, $10, $11,
1740
+ NOW(), NOW()
1741
+ )
1742
+ RETURNING id`,
1743
+ await this.generateUniqueContractTemplateSlug(tx as any, name),
1744
+ nextCode,
1745
+ name,
1746
+ this.normalizeOptionalText(data.description),
1747
+ data.contractCategory ?? 'client',
1748
+ data.contractType ?? 'service_agreement',
1749
+ data.billingModel ?? 'time_and_material',
1750
+ data.signatureStatus ?? 'not_started',
1751
+ isActive,
1752
+ nextStatus,
1753
+ this.normalizeOptionalText(data.contentHtml)
1754
+ )) as { id: number }[];
1755
+
1756
+ const templateId = created[0]?.id;
1757
+ if (!templateId) {
1758
+ throw new BadRequestException('Unable to create the contract template.');
1759
+ }
1760
+
1761
+ return this.getContractTemplateRecord(tx as any, templateId, true);
1762
+ });
1763
+ }
1764
+
1765
+ async updateContractTemplate(
1766
+ userId: number,
1767
+ templateId: number,
1768
+ data: Partial<ContractTemplatePayload>
1769
+ ) {
1770
+ const actor = await this.getActorContext(userId);
1771
+ this.ensureDirector(actor);
1772
+
1773
+ return this.prisma.$transaction(async (tx) => {
1774
+ const current = await this.getContractTemplateRecord(
1775
+ tx as any,
1776
+ templateId,
1777
+ true
1778
+ );
1779
+
1780
+ const nextName =
1781
+ data.name !== undefined
1782
+ ? this.normalizeOptionalText(data.name)
1783
+ : current.name;
1784
+
1785
+ if (!nextName) {
1786
+ throw new BadRequestException('Contract template name is required.');
1787
+ }
1788
+
1789
+ if (String(nextName).toLowerCase() !== String(current.name).toLowerCase()) {
1790
+ await this.assertContractTemplateNameAvailable(
1791
+ tx as any,
1792
+ nextName,
1793
+ templateId
1794
+ );
1795
+ }
1796
+
1797
+ const nextCode =
1798
+ data.code !== undefined
1799
+ ? this.normalizeOptionalText(data.code)?.toUpperCase() ?? null
1800
+ : (current.code ?? null);
1801
+
1802
+ if (nextCode) {
1803
+ await this.assertContractTemplateCodeAvailable(
1804
+ tx as any,
1805
+ nextCode,
1806
+ templateId
1807
+ );
1808
+ }
1809
+
1810
+ const nextStatus = data.status ?? current.status ?? 'active';
1811
+ const nextIsActive =
1812
+ data.isActive ?? !['inactive', 'archived'].includes(nextStatus);
1813
+ const nextSlug =
1814
+ String(nextName).toLowerCase() !== String(current.name).toLowerCase()
1815
+ ? await this.generateUniqueContractTemplateSlug(
1816
+ tx as any,
1817
+ nextName,
1818
+ templateId
1819
+ )
1820
+ : current.slug;
1821
+
1822
+ await (tx as any).$executeRawUnsafe(
1823
+ `UPDATE operations_contract_template
1824
+ SET slug = $1,
1825
+ code = $2,
1826
+ name = $3,
1827
+ description = $4,
1828
+ contract_category = $5,
1829
+ contract_type = $6,
1830
+ billing_model = $7,
1831
+ signature_status = $8,
1832
+ is_active = $9,
1833
+ status = $10,
1834
+ content_html = $11,
1835
+ updated_at = NOW()
1836
+ WHERE id = $12`,
1837
+ nextSlug,
1838
+ nextCode,
1839
+ nextName,
1840
+ data.description !== undefined
1841
+ ? this.normalizeOptionalText(data.description)
1842
+ : (current.description ?? null),
1843
+ data.contractCategory ?? current.contractCategory ?? 'client',
1844
+ data.contractType ?? current.contractType ?? 'service_agreement',
1845
+ data.billingModel ?? current.billingModel ?? 'time_and_material',
1846
+ data.signatureStatus ?? current.signatureStatus ?? 'not_started',
1847
+ nextIsActive,
1848
+ nextStatus,
1849
+ data.contentHtml !== undefined
1850
+ ? this.normalizeOptionalText(data.contentHtml)
1851
+ : (current.contentHtml ?? null),
1852
+ templateId
1853
+ );
1854
+
1855
+ return this.getContractTemplateRecord(tx as any, templateId, true);
1856
+ });
1857
+ }
1858
+
1859
+ async listContracts(userId: number) {
1860
+ const actor = await this.getActorContext(userId);
1861
+ const params: unknown[] = [];
1862
+ const accessClause = actor.isDirector
1863
+ ? 'c.deleted_at IS NULL'
1864
+ : `c.deleted_at IS NULL AND (
1865
+ c.related_collaborator_id = ANY(${this.param(params, actor.visibleCollaboratorIds)}::int[])
1866
+ OR EXISTS (
1867
+ SELECT 1
1868
+ FROM operations_project p_access
1869
+ WHERE p_access.contract_id = c.id
1870
+ AND p_access.deleted_at IS NULL
1871
+ AND p_access.id = ANY(${this.param(params, actor.visibleProjectIds)}::int[])
1872
+ )
1873
+ )`;
1874
+
1875
+ return this.queryRows(
1876
+ `SELECT c.id,
1877
+ c.code,
1878
+ c.name,
1879
+ c.contract_category AS "contractCategory",
1880
+ c.contract_type AS "contractType",
1881
+ c.client_name AS "clientName",
1882
+ c.signature_status AS "signatureStatus",
1883
+ c.is_active AS "isActive",
1884
+ c.billing_model AS "billingModel",
1885
+ c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
1886
+ c.related_collaborator_id AS "relatedCollaboratorId",
1887
+ c.contract_template_id AS "contractTemplateId",
1888
+ template.name AS "contractTemplateName",
1889
+ template.slug AS "contractTemplateSlug",
1890
+ template.code AS "contractTemplateCode",
1891
+ c.origin_type AS "originType",
1892
+ c.origin_id AS "originId",
1893
+ c.start_date AS "startDate",
1894
+ c.end_date AS "endDate",
1895
+ c.signed_at AS "signedAt",
1896
+ c.effective_date AS "effectiveDate",
1897
+ c.budget_amount AS "budgetAmount",
1898
+ c.monthly_hour_cap AS "monthlyHourCap",
1899
+ c.status,
1900
+ c.creation_mode AS "creationMode",
1901
+ c.wizard_step AS "wizardStep",
1902
+ c.description,
1903
+ m.display_name AS "accountManagerName",
1904
+ linked.display_name AS "relatedCollaboratorName",
1905
+ MAX(COALESCE(primary_party.display_name, linked.display_name, c.client_name)) AS "mainRelatedPartyName",
1906
+ MAX(COALESCE(financials.value_amount, 0)) AS "valueAmount",
1907
+ MAX(COALESCE(financials.payment_amount, 0)) AS "paymentAmount",
1908
+ MAX(COALESCE(financials.revenue_amount, 0)) AS "revenueAmount",
1909
+ MAX(COALESCE(financials.fine_amount, 0)) AS "fineAmount",
1910
+ MAX(COALESCE(pdf_document.file_name, '')) AS "currentPdfFileName",
1911
+ COUNT(DISTINCT p.id)::int AS "projectCount"
1912
+ FROM operations_contract c
1913
+ LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
1914
+ LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
1915
+ LEFT JOIN operations_contract_template template
1916
+ ON template.id = c.contract_template_id
1917
+ LEFT JOIN LATERAL (
1918
+ SELECT cp.display_name
1919
+ FROM operations_contract_party cp
1920
+ WHERE cp.contract_id = c.id
1921
+ AND cp.deleted_at IS NULL
1922
+ ORDER BY cp.is_primary DESC, cp.id ASC
1923
+ LIMIT 1
1924
+ ) primary_party ON TRUE
1925
+ LEFT JOIN LATERAL (
1926
+ SELECT
1927
+ SUM(CASE WHEN term_type = 'value' THEN amount ELSE 0 END) AS value_amount,
1928
+ SUM(CASE WHEN term_type = 'payment' THEN amount ELSE 0 END) AS payment_amount,
1929
+ SUM(CASE WHEN term_type = 'revenue' THEN amount ELSE 0 END) AS revenue_amount,
1930
+ SUM(CASE WHEN term_type = 'fine' THEN amount ELSE 0 END) AS fine_amount
1931
+ FROM operations_contract_financial_term ft
1932
+ WHERE ft.contract_id = c.id
1933
+ AND ft.deleted_at IS NULL
1934
+ ) financials ON TRUE
1935
+ LEFT JOIN LATERAL (
1936
+ SELECT cd.file_name
1937
+ FROM operations_contract_document cd
1938
+ WHERE cd.contract_id = c.id
1939
+ AND cd.deleted_at IS NULL
1940
+ AND cd.is_current = true
1941
+ AND cd.document_type IN ('source_upload', 'generated_pdf')
1942
+ ORDER BY cd.id DESC
1943
+ LIMIT 1
1944
+ ) pdf_document ON TRUE
1077
1945
  LEFT JOIN operations_project p
1078
1946
  ON p.contract_id = c.id
1079
1947
  AND p.deleted_at IS NULL
1080
1948
  WHERE ${accessClause}
1081
- GROUP BY c.id, m.id, linked.id
1082
- ORDER BY c.name ASC`,
1949
+ GROUP BY c.id, m.id, linked.id, template.id
1950
+ ORDER BY COALESCE(c.name, c.code, CONCAT('draft-', c.id)) ASC`,
1083
1951
  params
1084
1952
  );
1085
1953
  }
1086
1954
 
1087
- async getContractById(userId: number, contractId: number) {
1955
+ async getContractById(
1956
+ userId: number,
1957
+ contractId: number
1958
+ ): Promise<ContractDetailsRecord> {
1088
1959
  const actor = await this.getActorContext(userId);
1089
- const contract = await this.querySingle(
1960
+ const contract = await this.querySingle<ContractDetailRecord>(
1090
1961
  `SELECT c.id,
1091
1962
  c.code,
1092
1963
  c.name,
@@ -1098,6 +1969,10 @@ export class OperationsService {
1098
1969
  c.billing_model AS "billingModel",
1099
1970
  c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
1100
1971
  c.related_collaborator_id AS "relatedCollaboratorId",
1972
+ c.contract_template_id AS "contractTemplateId",
1973
+ template.name AS "contractTemplateName",
1974
+ template.slug AS "contractTemplateSlug",
1975
+ template.code AS "contractTemplateCode",
1101
1976
  c.origin_type AS "originType",
1102
1977
  c.origin_id AS "originId",
1103
1978
  c.start_date AS "startDate",
@@ -1107,6 +1982,8 @@ export class OperationsService {
1107
1982
  c.budget_amount AS "budgetAmount",
1108
1983
  c.monthly_hour_cap AS "monthlyHourCap",
1109
1984
  c.status,
1985
+ c.creation_mode AS "creationMode",
1986
+ c.wizard_step AS "wizardStep",
1110
1987
  c.description,
1111
1988
  c.content_html AS "contentHtml",
1112
1989
  m.display_name AS "accountManagerName",
@@ -1114,6 +1991,8 @@ export class OperationsService {
1114
1991
  FROM operations_contract c
1115
1992
  LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
1116
1993
  LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
1994
+ LEFT JOIN operations_contract_template template
1995
+ ON template.id = c.contract_template_id
1117
1996
  WHERE c.id = $1
1118
1997
  AND c.deleted_at IS NULL`,
1119
1998
  [contractId]
@@ -1149,7 +2028,7 @@ export class OperationsService {
1149
2028
 
1150
2029
  const [projects, scheduleSummary, parties, signatures, financialTerms, documents, revisions, history] =
1151
2030
  await Promise.all([
1152
- this.queryRows(
2031
+ this.queryRows<ContractProjectSummary>(
1153
2032
  `SELECT id, code, name, status
1154
2033
  FROM operations_project
1155
2034
  WHERE contract_id = $1
@@ -1158,7 +2037,7 @@ export class OperationsService {
1158
2037
  [contractId]
1159
2038
  ),
1160
2039
  contract.relatedCollaboratorId
1161
- ? this.queryRows(
2040
+ ? this.queryRows<ContractScheduleDay>(
1162
2041
  `SELECT weekday,
1163
2042
  is_working_day AS "isWorkingDay",
1164
2043
  start_time AS "startTime",
@@ -1170,8 +2049,8 @@ export class OperationsService {
1170
2049
  ORDER BY id ASC`,
1171
2050
  [contract.relatedCollaboratorId]
1172
2051
  )
1173
- : Promise.resolve([]),
1174
- this.queryRows(
2052
+ : Promise.resolve<ContractScheduleDay[]>([]),
2053
+ this.queryRows<NonNullable<ContractPayload['parties']>[number]>(
1175
2054
  `SELECT id,
1176
2055
  party_role AS "partyRole",
1177
2056
  party_type AS "partyType",
@@ -1186,7 +2065,7 @@ export class OperationsService {
1186
2065
  ORDER BY is_primary DESC, id ASC`,
1187
2066
  [contractId]
1188
2067
  ),
1189
- this.queryRows(
2068
+ this.queryRows<NonNullable<ContractPayload['signatures']>[number]>(
1190
2069
  `SELECT id,
1191
2070
  signer_name AS "signerName",
1192
2071
  signer_role AS "signerRole",
@@ -1199,7 +2078,7 @@ export class OperationsService {
1199
2078
  ORDER BY id ASC`,
1200
2079
  [contractId]
1201
2080
  ),
1202
- this.queryRows(
2081
+ this.queryRows<NonNullable<ContractPayload['financialTerms']>[number]>(
1203
2082
  `SELECT id,
1204
2083
  term_type AS "termType",
1205
2084
  label,
@@ -1213,13 +2092,16 @@ export class OperationsService {
1213
2092
  ORDER BY id ASC`,
1214
2093
  [contractId]
1215
2094
  ),
1216
- this.queryRows(
2095
+ this.queryRows<ContractDocumentRecord>(
1217
2096
  `SELECT id,
1218
2097
  document_type AS "documentType",
2098
+ file_id AS "fileId",
1219
2099
  file_name AS "fileName",
1220
2100
  mime_type AS "mimeType",
1221
2101
  file_content_base64 AS "fileContentBase64",
1222
2102
  is_current AS "isCurrent",
2103
+ extraction_status AS "extractionStatus",
2104
+ extraction_summary AS "extractionSummary",
1223
2105
  notes,
1224
2106
  created_at AS "createdAt"
1225
2107
  FROM operations_contract_document
@@ -1228,7 +2110,7 @@ export class OperationsService {
1228
2110
  ORDER BY is_current DESC, id DESC`,
1229
2111
  [contractId]
1230
2112
  ),
1231
- this.queryRows(
2113
+ this.queryRows<NonNullable<ContractPayload['revisions']>[number]>(
1232
2114
  `SELECT id,
1233
2115
  revision_type AS "revisionType",
1234
2116
  title,
@@ -1241,7 +2123,7 @@ export class OperationsService {
1241
2123
  ORDER BY effective_date DESC NULLS LAST, id DESC`,
1242
2124
  [contractId]
1243
2125
  ),
1244
- this.queryRows(
2126
+ this.queryRows<ContractHistoryRecord>(
1245
2127
  `SELECT id,
1246
2128
  actor_user_id AS "actorUserId",
1247
2129
  action,
@@ -1275,9 +2157,11 @@ export class OperationsService {
1275
2157
  async createContract(userId: number, data: ContractPayload) {
1276
2158
  const actor = await this.getActorContext(userId);
1277
2159
  this.ensureDirector(actor);
1278
- this.requireFields(data, ['code', 'name', 'clientName', 'startDate']);
1279
2160
 
1280
2161
  const createdId = await this.prisma.$transaction(async (tx) => {
2162
+ const normalizedCode =
2163
+ this.normalizeOptionalText(data.code)?.toUpperCase() ??
2164
+ (await this.generateContractCode(tx as any));
1281
2165
  const created = await (tx as any).$queryRawUnsafe(
1282
2166
  `INSERT INTO operations_contract (
1283
2167
  code,
@@ -1290,6 +2174,7 @@ export class OperationsService {
1290
2174
  billing_model,
1291
2175
  account_manager_collaborator_id,
1292
2176
  related_collaborator_id,
2177
+ contract_template_id,
1293
2178
  origin_type,
1294
2179
  origin_id,
1295
2180
  start_date,
@@ -1299,35 +2184,57 @@ export class OperationsService {
1299
2184
  budget_amount,
1300
2185
  monthly_hour_cap,
1301
2186
  status,
2187
+ creation_mode,
2188
+ wizard_step,
1302
2189
  description,
1303
2190
  content_html,
1304
2191
  created_at,
1305
2192
  updated_at
1306
2193
  ) 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()
2194
+ $1, $2,
2195
+ $3::operations_contract_contract_category_70d553ea09_enum,
2196
+ $4::operations_contract_contract_type_48331e2ebf_enum,
2197
+ $5,
2198
+ $6::operations_contract_signature_status_2cb7282a7b_enum,
2199
+ $7,
2200
+ $8::operations_contract_billing_model_409dc7fea2_enum,
2201
+ $9,
2202
+ $10,
2203
+ $11,
2204
+ $12::operations_contract_origin_type_07a7cc2b5d_enum,
2205
+ $13,
2206
+ $14::date,
2207
+ $15::date, $16::date, $17::date, $18, $19,
2208
+ $20::operations_contract_status_a0395962df_enum,
2209
+ $21::operations_contract_creation_mode_98ba669209_enum,
2210
+ $22,
2211
+ $23,
2212
+ $24,
2213
+ NOW(), NOW()
1310
2214
  )
1311
2215
  RETURNING id`,
1312
- data.code,
1313
- data.name,
2216
+ normalizedCode,
2217
+ this.normalizeOptionalText(data.name),
1314
2218
  data.contractCategory ?? 'client',
1315
2219
  data.contractType ?? 'service_agreement',
1316
- data.clientName,
2220
+ this.normalizeOptionalText(data.clientName),
1317
2221
  data.signatureStatus ?? 'not_started',
1318
2222
  data.isActive ?? true,
1319
2223
  data.billingModel ?? 'time_and_material',
1320
2224
  data.accountManagerCollaboratorId ?? null,
1321
2225
  data.relatedCollaboratorId ?? null,
2226
+ data.contractTemplateId ?? null,
1322
2227
  data.originType ?? 'manual',
1323
2228
  data.originId ?? null,
1324
- data.startDate,
2229
+ this.normalizeOptionalText(data.startDate ?? null),
1325
2230
  data.endDate ?? null,
1326
2231
  data.signedAt ?? null,
1327
- data.effectiveDate ?? data.startDate,
2232
+ data.effectiveDate ?? data.startDate ?? null,
1328
2233
  data.budgetAmount ?? null,
1329
2234
  data.monthlyHourCap ?? null,
1330
2235
  data.status ?? 'draft',
2236
+ data.creationMode ?? 'blank',
2237
+ data.wizardStep ?? 0,
1331
2238
  data.description ?? null,
1332
2239
  data.contentHtml ?? null
1333
2240
  );
@@ -1342,9 +2249,10 @@ export class OperationsService {
1342
2249
  );
1343
2250
  await this.replaceContractRevisions(tx as any, contractId, data.revisions);
1344
2251
  if (data.replaceUploadedPdfDocument) {
1345
- await this.replaceContractPdfDocument(
2252
+ await this.replaceContractDocument(
1346
2253
  tx as any,
1347
2254
  contractId,
2255
+ 'source_upload',
1348
2256
  data.replaceUploadedPdfDocument
1349
2257
  );
1350
2258
  }
@@ -1363,94 +2271,1263 @@ export class OperationsService {
1363
2271
  return this.getContractById(userId, createdId);
1364
2272
  }
1365
2273
 
1366
- async updateContract(userId: number, contractId: number, data: Partial<ContractPayload>) {
2274
+ async createContractDraft(userId: number, data: ContractDraftPayload) {
1367
2275
  const actor = await this.getActorContext(userId);
1368
2276
  this.ensureDirector(actor);
1369
- await this.getContractById(userId, contractId);
1370
2277
 
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);
2278
+ const creationMode = this.normalizeEnumValue(
2279
+ data.creationMode,
2280
+ CONTRACT_CREATION_MODE_VALUES,
2281
+ 'blank'
2282
+ );
2283
+ const selectedTemplate =
2284
+ data.templateId && data.templateId > 0
2285
+ ? await this.getContractTemplateById(userId, data.templateId)
2286
+ : null;
2287
+ const duplicateSource =
2288
+ data.duplicateFromId && data.duplicateFromId > 0
2289
+ ? await this.getContractById(userId, data.duplicateFromId)
2290
+ : null;
2291
+ const storedSourceFile =
2292
+ data.sourceFileId && data.sourceFileId > 0
2293
+ ? await this.prisma.file.findUnique({
2294
+ where: { id: data.sourceFileId },
2295
+ include: { file_mimetype: true },
2296
+ })
2297
+ : null;
2298
+
2299
+ if (data.sourceFileId && !storedSourceFile) {
2300
+ throw new NotFoundException('Source contract file not found.');
2301
+ }
1394
2302
 
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
- }
2303
+ const createdId = await this.prisma.$transaction(async (tx) => {
2304
+ const generatedCode = await this.generateContractCode(tx as any);
2305
+ const duplicateNameBase =
2306
+ this.normalizeOptionalText(duplicateSource?.name) ??
2307
+ this.normalizeOptionalText(duplicateSource?.code) ??
2308
+ 'Contract';
2309
+ const created = await (tx as any).$queryRawUnsafe(
2310
+ `INSERT INTO operations_contract (
2311
+ code,
2312
+ name,
2313
+ contract_category,
2314
+ contract_type,
2315
+ client_name,
2316
+ signature_status,
2317
+ is_active,
2318
+ billing_model,
2319
+ account_manager_collaborator_id,
2320
+ related_collaborator_id,
2321
+ contract_template_id,
2322
+ origin_type,
2323
+ origin_id,
2324
+ start_date,
2325
+ end_date,
2326
+ signed_at,
2327
+ effective_date,
2328
+ budget_amount,
2329
+ monthly_hour_cap,
2330
+ status,
2331
+ creation_mode,
2332
+ wizard_step,
2333
+ description,
2334
+ content_html,
2335
+ created_by_user_id,
2336
+ updated_by_user_id,
2337
+ created_at,
2338
+ updated_at
2339
+ ) VALUES (
2340
+ $1,
2341
+ $2,
2342
+ $3::operations_contract_contract_category_70d553ea09_enum,
2343
+ $4::operations_contract_contract_type_48331e2ebf_enum,
2344
+ $5,
2345
+ $6::operations_contract_signature_status_2cb7282a7b_enum,
2346
+ $7,
2347
+ $8::operations_contract_billing_model_409dc7fea2_enum,
2348
+ $9,
2349
+ $10,
2350
+ $11,
2351
+ $12::operations_contract_origin_type_07a7cc2b5d_enum,
2352
+ $13,
2353
+ $14::date,
2354
+ $15::date,
2355
+ $16::date,
2356
+ $17::date,
2357
+ $18,
2358
+ $19,
2359
+ $20::operations_contract_status_a0395962df_enum,
2360
+ $21::operations_contract_creation_mode_98ba669209_enum,
2361
+ $22,
2362
+ $23,
2363
+ $24,
2364
+ $25,
2365
+ $25,
2366
+ NOW(),
2367
+ NOW()
2368
+ )
2369
+ RETURNING id`,
2370
+ generatedCode,
2371
+ creationMode === 'duplicate'
2372
+ ? `${duplicateNameBase} Copy`
2373
+ : this.normalizeOptionalText(selectedTemplate?.name),
2374
+ duplicateSource?.contractCategory ??
2375
+ selectedTemplate?.contractCategory ??
2376
+ 'client',
2377
+ duplicateSource?.contractType ??
2378
+ selectedTemplate?.contractType ??
2379
+ 'service_agreement',
2380
+ this.normalizeOptionalText(duplicateSource?.clientName),
2381
+ duplicateSource?.signatureStatus ??
2382
+ selectedTemplate?.signatureStatus ??
2383
+ 'not_started',
2384
+ duplicateSource?.isActive ?? true,
2385
+ duplicateSource?.billingModel ??
2386
+ selectedTemplate?.billingModel ??
2387
+ 'time_and_material',
2388
+ duplicateSource?.accountManagerCollaboratorId ?? null,
2389
+ duplicateSource?.relatedCollaboratorId ?? null,
2390
+ duplicateSource?.contractTemplateId ?? selectedTemplate?.id ?? null,
2391
+ duplicateSource?.originType ?? 'manual',
2392
+ duplicateSource?.originId ?? null,
2393
+ duplicateSource?.startDate ?? null,
2394
+ duplicateSource?.endDate ?? null,
2395
+ duplicateSource?.signedAt ?? null,
2396
+ duplicateSource?.effectiveDate ?? null,
2397
+ duplicateSource?.budgetAmount ?? null,
2398
+ duplicateSource?.monthlyHourCap ?? null,
2399
+ 'draft',
2400
+ creationMode,
2401
+ creationMode === 'upload' ? 0 : creationMode === 'blank' ? 0 : 1,
2402
+ duplicateSource?.description ?? selectedTemplate?.description ?? null,
2403
+ duplicateSource?.contentHtml ?? selectedTemplate?.contentHtml ?? null,
2404
+ userId
2405
+ );
1406
2406
 
1407
- if (data.parties) {
1408
- await this.replaceContractParties(tx as any, contractId, data.parties);
1409
- }
1410
- if (data.signatures) {
2407
+ const contractId = (created as { id: number }[])[0]?.id;
2408
+
2409
+ if (duplicateSource) {
2410
+ await this.replaceContractParties(
2411
+ tx as any,
2412
+ contractId,
2413
+ duplicateSource.parties
2414
+ );
1411
2415
  await this.replaceContractSignatures(
1412
2416
  tx as any,
1413
2417
  contractId,
1414
- data.signatures
2418
+ duplicateSource.signatures
1415
2419
  );
1416
- }
1417
- if (data.financialTerms) {
1418
2420
  await this.replaceContractFinancialTerms(
1419
2421
  tx as any,
1420
2422
  contractId,
1421
- data.financialTerms
2423
+ duplicateSource.financialTerms
1422
2424
  );
1423
- }
1424
- if (data.revisions) {
1425
2425
  await this.replaceContractRevisions(
1426
2426
  tx as any,
1427
2427
  contractId,
1428
- data.revisions
2428
+ duplicateSource.revisions
1429
2429
  );
2430
+
2431
+ const currentSourceDocument =
2432
+ duplicateSource.documents.find(
2433
+ (document) => document.isCurrent && document.documentType === 'source_upload'
2434
+ ) ?? null;
2435
+
2436
+ if (currentSourceDocument) {
2437
+ await this.replaceContractDocument(
2438
+ tx as any,
2439
+ contractId,
2440
+ 'source_upload',
2441
+ {
2442
+ fileId: currentSourceDocument.fileId ?? null,
2443
+ fileName: currentSourceDocument.fileName,
2444
+ mimeType: currentSourceDocument.mimeType,
2445
+ fileContentBase64: currentSourceDocument.fileContentBase64 ?? null,
2446
+ notes: currentSourceDocument.notes ?? null,
2447
+ extractionStatus: currentSourceDocument.extractionStatus ?? 'skipped',
2448
+ extractionSummary: currentSourceDocument.extractionSummary ?? null,
2449
+ }
2450
+ );
2451
+ }
1430
2452
  }
1431
- if (data.replaceUploadedPdfDocument) {
1432
- await this.replaceContractPdfDocument(
1433
- tx as any,
1434
- contractId,
1435
- data.replaceUploadedPdfDocument
1436
- );
2453
+
2454
+ if (storedSourceFile) {
2455
+ await this.replaceContractDocument(tx as any, contractId, 'source_upload', {
2456
+ fileId: storedSourceFile.id,
2457
+ fileName:
2458
+ this.normalizeOptionalText(data.sourceFileName) ??
2459
+ storedSourceFile.filename,
2460
+ mimeType:
2461
+ this.normalizeOptionalText(data.sourceMimeType) ??
2462
+ storedSourceFile.file_mimetype?.name ??
2463
+ 'application/pdf',
2464
+ notes: 'Source contract document uploaded during draft creation.',
2465
+ extractionStatus: 'pending',
2466
+ extractionSummary: null,
2467
+ });
1437
2468
  }
1438
2469
 
1439
2470
  await this.insertContractHistory(
1440
2471
  tx as any,
1441
2472
  contractId,
1442
2473
  userId,
1443
- 'updated',
1444
- 'Contract registry data updated.'
2474
+ 'draft_created',
2475
+ `Contract draft created with mode ${creationMode}.`,
2476
+ JSON.stringify({
2477
+ creationMode,
2478
+ templateId: selectedTemplate?.id ?? null,
2479
+ duplicateFromId: duplicateSource?.id ?? null,
2480
+ sourceFileId: storedSourceFile?.id ?? null,
2481
+ })
1445
2482
  );
2483
+
2484
+ return contractId;
1446
2485
  });
1447
2486
 
1448
- return this.getContractById(userId, contractId);
2487
+ return this.getContractById(userId, createdId);
1449
2488
  }
1450
2489
 
1451
- async listTimesheets(userId: number) {
2490
+ async extractContractSource(
2491
+ userId: number,
2492
+ contractId: number,
2493
+ data: Omit<ContractExtractDraftPayload, 'fileName' | 'mimeType' | 'fileContentBase64' | 'contractId'>
2494
+ ) {
1452
2495
  const actor = await this.getActorContext(userId);
1453
- const filter = this.buildIdFilter(actor.visibleCollaboratorIds, 't.collaborator_id', actor.isDirector);
2496
+ this.ensureDirector(actor);
2497
+
2498
+ const contract = await this.getContractById(userId, contractId);
2499
+ const sourceDocument = contract.documents.find(
2500
+ (document) => document.isCurrent && document.documentType === 'source_upload'
2501
+ );
2502
+
2503
+ if (!sourceDocument) {
2504
+ throw new BadRequestException(
2505
+ 'No source document is attached to this contract draft.'
2506
+ );
2507
+ }
2508
+
2509
+ await this.prisma.$executeRawUnsafe(
2510
+ `UPDATE operations_contract_document
2511
+ SET extraction_status = 'processing',
2512
+ updated_at = NOW()
2513
+ WHERE id = $1`,
2514
+ sourceDocument.id
2515
+ );
2516
+
2517
+ try {
2518
+ const extracted = await this.extractContractDraft(userId, {
2519
+ contractId,
2520
+ provider: data.provider ?? null,
2521
+ promptMessage: data.promptMessage ?? null,
2522
+ });
2523
+
2524
+ await this.prisma.$executeRawUnsafe(
2525
+ `UPDATE operations_contract_document
2526
+ SET extraction_status = 'completed',
2527
+ extraction_summary = $2,
2528
+ updated_at = NOW()
2529
+ WHERE id = $1`,
2530
+ sourceDocument.id,
2531
+ extracted.summary || extracted.description || extracted.name || null
2532
+ );
2533
+
2534
+ await this.insertContractHistory(
2535
+ this.prisma,
2536
+ contractId,
2537
+ userId,
2538
+ 'source_extracted',
2539
+ 'Source contract document extracted with AI.'
2540
+ );
2541
+
2542
+ return extracted;
2543
+ } catch (error) {
2544
+ await this.prisma.$executeRawUnsafe(
2545
+ `UPDATE operations_contract_document
2546
+ SET extraction_status = 'failed',
2547
+ updated_at = NOW()
2548
+ WHERE id = $1`,
2549
+ sourceDocument.id
2550
+ );
2551
+ throw error;
2552
+ }
2553
+ }
2554
+
2555
+ async generateContractContent(
2556
+ userId: number,
2557
+ contractId: number,
2558
+ data: ContractGenerateContentPayload = {}
2559
+ ) {
2560
+ const actor = await this.getActorContext(userId);
2561
+ this.ensureDirector(actor);
2562
+
2563
+ const contract = await this.getContractById(userId, contractId);
2564
+ const contentHtml = await this.generateContractContentHtml(contract, data);
2565
+
2566
+ await this.prisma.$transaction(async (tx) => {
2567
+ await (tx as any).$executeRawUnsafe(
2568
+ `UPDATE operations_contract
2569
+ SET content_html = $2,
2570
+ wizard_step = CASE
2571
+ WHEN COALESCE(wizard_step, 0) < 4 THEN 4
2572
+ ELSE wizard_step
2573
+ END,
2574
+ updated_by_user_id = $3,
2575
+ updated_at = NOW()
2576
+ WHERE id = $1`,
2577
+ contractId,
2578
+ contentHtml,
2579
+ userId
2580
+ );
2581
+
2582
+ await this.insertContractHistory(
2583
+ tx as any,
2584
+ contractId,
2585
+ userId,
2586
+ 'content_generated',
2587
+ 'Contract content generated automatically for editing.',
2588
+ JSON.stringify({
2589
+ provider: data.provider ?? null,
2590
+ overwrite: Boolean(data.overwrite),
2591
+ hasPrompt: Boolean(this.normalizeOptionalText(data.promptMessage)),
2592
+ })
2593
+ );
2594
+ });
2595
+
2596
+ return this.getContractById(userId, contractId);
2597
+ }
2598
+
2599
+ async reviewContractLegally(
2600
+ userId: number,
2601
+ contractId: number,
2602
+ data: ContractLegalReviewPayload = {}
2603
+ ) {
2604
+ const actor = await this.getActorContext(userId);
2605
+ this.ensureDirector(actor);
2606
+
2607
+ const contract = await this.getContractById(userId, contractId);
2608
+ const review = await this.buildContractLegalReview(contract, data);
2609
+
2610
+ await this.insertContractHistory(
2611
+ this.prisma,
2612
+ contractId,
2613
+ userId,
2614
+ 'legal_reviewed',
2615
+ 'Advisory legal checklist updated for this contract.',
2616
+ JSON.stringify(review)
2617
+ );
2618
+
2619
+ return review;
2620
+ }
2621
+
2622
+ async generateContractPdf(userId: number, contractId: number) {
2623
+ const actor = await this.getActorContext(userId);
2624
+ this.ensureDirector(actor);
2625
+
2626
+ const contract = await this.getContractById(userId, contractId);
2627
+ const pdfBuffer = await this.renderContractPdfBuffer(contract);
2628
+ const fileName = this.buildContractPdfFileName(contract);
2629
+ const uploadedFile = await this.fileService.upload('operations/contracts/generated', {
2630
+ fieldname: 'file',
2631
+ originalname: fileName,
2632
+ encoding: '7bit',
2633
+ mimetype: 'application/pdf',
2634
+ size: pdfBuffer.length,
2635
+ destination: '',
2636
+ filename: fileName,
2637
+ path: '',
2638
+ buffer: pdfBuffer,
2639
+ });
2640
+
2641
+ await this.prisma.$transaction(async (tx) => {
2642
+ await this.replaceContractDocument(tx as any, contractId, 'generated_pdf', {
2643
+ fileId: uploadedFile.id,
2644
+ fileName,
2645
+ mimeType: 'application/pdf',
2646
+ notes: 'PDF generated from contract rich text content.',
2647
+ extractionStatus: 'skipped',
2648
+ extractionSummary: null,
2649
+ });
2650
+
2651
+ await this.insertContractHistory(
2652
+ tx as any,
2653
+ contractId,
2654
+ userId,
2655
+ 'pdf_generated',
2656
+ 'Generated a branded PDF version of the contract.'
2657
+ );
2658
+ });
2659
+
2660
+ return {
2661
+ contractId,
2662
+ fileId: uploadedFile.id,
2663
+ fileName,
2664
+ mimeType: 'application/pdf',
2665
+ documentType: 'generated_pdf',
2666
+ downloadUrl: `/file/open/${uploadedFile.id}`,
2667
+ };
2668
+ }
2669
+
2670
+ async extractContractDraft(
2671
+ userId: number,
2672
+ data: ContractExtractDraftPayload
2673
+ ) {
2674
+ const actor = await this.getActorContext(userId);
2675
+ this.ensureDirector(actor);
2676
+
2677
+ const uploadFile = await this.resolveContractExtractionFile(userId, data);
2678
+
2679
+ const aiResult = await this.aiService.chat(
2680
+ {
2681
+ provider: data.provider === 'gemini' ? 'gemini' : 'openai',
2682
+ model: data.provider === 'gemini' ? 'gemini-1.5-flash' : 'gpt-4o-mini',
2683
+ message:
2684
+ this.normalizeExtractionString(data.promptMessage) ||
2685
+ 'Analyze the attached contract and extract a structured draft for the contract form.',
2686
+ systemPrompt: this.buildContractExtractionSystemPrompt(),
2687
+ },
2688
+ uploadFile,
2689
+ );
2690
+
2691
+ const parsed = this.parseAiJsonPayload(String(aiResult?.content ?? ''));
2692
+ const draft = this.normalizeContractExtractDraft(parsed);
2693
+ const warnings = [...draft.warnings];
2694
+
2695
+ if (
2696
+ uploadFile.mimetype === 'application/msword' ||
2697
+ uploadFile.mimetype ===
2698
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
2699
+ ) {
2700
+ warnings.push(
2701
+ 'Word extraction is best-effort. Review names, dates, and values before saving.'
2702
+ );
2703
+ }
2704
+
2705
+ return {
2706
+ ...draft,
2707
+ warnings: Array.from(new Set(warnings.filter(Boolean))),
2708
+ };
2709
+ }
2710
+
2711
+ async createContractFromProposalIntegration(
2712
+ payload: ProposalApprovedEventPayload,
2713
+ ) {
2714
+ const sourceEntityId = String(payload.proposalId || '').trim();
2715
+
2716
+ if (!sourceEntityId) {
2717
+ throw new BadRequestException('proposalId is required for CRM proposal integration.');
2718
+ }
2719
+
2720
+ const proposal = payload.proposal || {
2721
+ code: payload.code ?? null,
2722
+ title: payload.title ?? null,
2723
+ contractCategory: payload.commercialTerms?.contractCategory ?? 'client',
2724
+ contractType: payload.commercialTerms?.contractType ?? 'service_agreement',
2725
+ billingModel: payload.commercialTerms?.billingModel ?? 'fixed_price',
2726
+ validFrom: payload.commercialTerms?.validFrom ?? null,
2727
+ validUntil:
2728
+ payload.validUntil ?? payload.commercialTerms?.validUntil ?? null,
2729
+ totalAmount: payload.total ?? null,
2730
+ totalAmountCents:
2731
+ payload.total != null ? Math.round(Number(payload.total) * 100) : null,
2732
+ notes: payload.commercialTerms?.notes ?? null,
2733
+ };
2734
+ const person = payload.person || {};
2735
+ const items = Array.isArray(payload.items) ? payload.items : [];
2736
+ const contractCategory = proposal.contractCategory ?? 'client';
2737
+ const contractType = proposal.contractType ?? 'service_agreement';
2738
+ const billingModel = proposal.billingModel ?? 'fixed_price';
2739
+ const clientName =
2740
+ this.normalizeOptionalText(person.tradeName) ??
2741
+ this.normalizeOptionalText(person.name) ??
2742
+ this.normalizeOptionalText(proposal.title) ??
2743
+ `Proposal ${sourceEntityId}`;
2744
+ const contractName =
2745
+ this.normalizeOptionalText(proposal.title) ?? `Proposal ${sourceEntityId}`;
2746
+
2747
+ const financialTerms = items
2748
+ .map((item) => ({
2749
+ termType: this.normalizeEnumValue(
2750
+ item.termType,
2751
+ FINANCIAL_TERM_TYPE_VALUES,
2752
+ 'value',
2753
+ ),
2754
+ label: this.normalizeOptionalText(item.name) ?? 'Commercial term',
2755
+ amount: Number(item.amount ?? Number(item.totalAmountCents || 0) / 100),
2756
+ recurrence: this.normalizeEnumValue(
2757
+ item.recurrence,
2758
+ RECURRENCE_VALUES,
2759
+ 'one_time',
2760
+ ),
2761
+ dueDay: item.dueDay ?? null,
2762
+ notes: this.normalizeOptionalText(item.description),
2763
+ }))
2764
+ .filter((term) => Number.isFinite(term.amount) && term.amount > 0);
2765
+
2766
+ const fallbackAmount = Number(proposal.totalAmount ?? 0);
2767
+ if (financialTerms.length === 0 && Number.isFinite(fallbackAmount) && fallbackAmount > 0) {
2768
+ financialTerms.push({
2769
+ termType: 'revenue',
2770
+ label: contractName,
2771
+ amount: fallbackAmount,
2772
+ recurrence: 'one_time',
2773
+ dueDay: null,
2774
+ notes: this.normalizeOptionalText(proposal.notes),
2775
+ });
2776
+ }
2777
+
2778
+ const primaryPartyRole = ['employee', 'contractor'].includes(contractCategory)
2779
+ ? 'employee'
2780
+ : ['supplier', 'vendor'].includes(contractCategory)
2781
+ ? 'supplier'
2782
+ : contractCategory === 'partner'
2783
+ ? 'partner'
2784
+ : 'client';
2785
+
2786
+ return this.prisma.$transaction(async (tx) => {
2787
+ await (tx as any).$queryRawUnsafe(
2788
+ `SELECT pg_advisory_xact_lock(hashtext($1))`,
2789
+ `operations:crm_proposal:${sourceEntityId}`,
2790
+ );
2791
+
2792
+ const existingContracts = await (tx as any).$queryRawUnsafe(
2793
+ `SELECT id, code
2794
+ FROM operations_contract
2795
+ WHERE deleted_at IS NULL
2796
+ AND origin_type = $1::operations_contract_origin_type_07a7cc2b5d_enum
2797
+ AND origin_id::text = $2
2798
+ ORDER BY id DESC
2799
+ LIMIT 1`,
2800
+ 'crm_proposal',
2801
+ sourceEntityId,
2802
+ );
2803
+
2804
+ const existingContract = (
2805
+ existingContracts as Array<{ id: number; code: string | null }>
2806
+ )[0];
2807
+
2808
+ if (existingContract?.id) {
2809
+ return {
2810
+ id: existingContract.id,
2811
+ code: existingContract.code ?? `CONTRACT-${existingContract.id}`,
2812
+ };
2813
+ }
2814
+
2815
+ const generatedCode = await this.generateContractCode(tx as any);
2816
+ const created = await (tx as any).$queryRawUnsafe(
2817
+ `INSERT INTO operations_contract (
2818
+ code,
2819
+ name,
2820
+ contract_category,
2821
+ contract_type,
2822
+ client_name,
2823
+ signature_status,
2824
+ is_active,
2825
+ billing_model,
2826
+ account_manager_collaborator_id,
2827
+ related_collaborator_id,
2828
+ contract_template_id,
2829
+ origin_type,
2830
+ origin_id,
2831
+ start_date,
2832
+ end_date,
2833
+ signed_at,
2834
+ effective_date,
2835
+ budget_amount,
2836
+ monthly_hour_cap,
2837
+ status,
2838
+ creation_mode,
2839
+ wizard_step,
2840
+ description,
2841
+ content_html,
2842
+ created_by_user_id,
2843
+ updated_by_user_id,
2844
+ created_at,
2845
+ updated_at
2846
+ ) VALUES (
2847
+ $1,
2848
+ $2,
2849
+ $3::operations_contract_contract_category_70d553ea09_enum,
2850
+ $4::operations_contract_contract_type_48331e2ebf_enum,
2851
+ $5,
2852
+ $6::operations_contract_signature_status_2cb7282a7b_enum,
2853
+ $7,
2854
+ $8::operations_contract_billing_model_409dc7fea2_enum,
2855
+ $9,
2856
+ $10,
2857
+ $11,
2858
+ $12::operations_contract_origin_type_07a7cc2b5d_enum,
2859
+ $13,
2860
+ $14::date,
2861
+ $15::date,
2862
+ $16::date,
2863
+ $17::date,
2864
+ $18,
2865
+ $19,
2866
+ $20::operations_contract_status_a0395962df_enum,
2867
+ $21::operations_contract_creation_mode_98ba669209_enum,
2868
+ $22,
2869
+ $23,
2870
+ $24,
2871
+ $25,
2872
+ $25,
2873
+ NOW(),
2874
+ NOW()
2875
+ )
2876
+ RETURNING id`,
2877
+ generatedCode,
2878
+ contractName,
2879
+ contractCategory,
2880
+ contractType,
2881
+ clientName,
2882
+ 'not_started',
2883
+ true,
2884
+ billingModel,
2885
+ null,
2886
+ null,
2887
+ null,
2888
+ 'crm_proposal',
2889
+ sourceEntityId,
2890
+ proposal.validFrom ?? null,
2891
+ proposal.validUntil ?? null,
2892
+ null,
2893
+ proposal.validFrom ?? null,
2894
+ fallbackAmount > 0 ? fallbackAmount : null,
2895
+ null,
2896
+ 'draft',
2897
+ 'blank',
2898
+ 1,
2899
+ this.normalizeOptionalText(proposal.notes),
2900
+ this.normalizeOptionalText(payload.revision?.contentHtml),
2901
+ Number(payload.approvedByUserId) || null,
2902
+ );
2903
+
2904
+ const contractId = (created as { id: number }[])[0]?.id;
2905
+ if (!contractId) {
2906
+ throw new BadRequestException('Could not create contract draft from proposal.');
2907
+ }
2908
+
2909
+ await this.replaceContractParties(tx as any, contractId, [
2910
+ {
2911
+ partyRole: primaryPartyRole as any,
2912
+ partyType: person.tradeName ? 'company' : 'individual',
2913
+ displayName: clientName,
2914
+ documentNumber: this.normalizeOptionalText(person.document),
2915
+ email: this.normalizeOptionalText(person.email),
2916
+ phone: this.normalizeOptionalText(person.phone),
2917
+ isPrimary: true,
2918
+ },
2919
+ ]);
2920
+
2921
+ await this.replaceContractFinancialTerms(
2922
+ tx as any,
2923
+ contractId,
2924
+ financialTerms.map((term) => ({
2925
+ termType: term.termType as any,
2926
+ label: term.label,
2927
+ amount: term.amount,
2928
+ recurrence: term.recurrence as any,
2929
+ dueDay: term.dueDay,
2930
+ notes: term.notes,
2931
+ })),
2932
+ );
2933
+
2934
+ await this.replaceContractRevisions(tx as any, contractId, [
2935
+ {
2936
+ revisionType: 'revision',
2937
+ title: payload.revision?.title ?? contractName,
2938
+ effectiveDate: proposal.validFrom ?? null,
2939
+ status: 'draft',
2940
+ summary:
2941
+ this.normalizeOptionalText(payload.revision?.summary) ??
2942
+ this.normalizeOptionalText(proposal.notes),
2943
+ },
2944
+ ]);
2945
+
2946
+ await this.insertContractHistory(
2947
+ tx as any,
2948
+ contractId,
2949
+ Number(payload.approvedByUserId) || null,
2950
+ 'created',
2951
+ `Draft contract generated from CRM proposal ${proposal.code ?? sourceEntityId}.`,
2952
+ JSON.stringify({
2953
+ correlationId: payload.correlationId || `proposal:${sourceEntityId}`,
2954
+ proposalId:
2955
+ payload.proposalId ??
2956
+ (Number(sourceEntityId) > 0 ? Number(sourceEntityId) : null),
2957
+ proposalRevisionId: payload.proposalRevisionId ?? null,
2958
+ sourceModule: 'contact',
2959
+ sourceEntity: 'proposal',
2960
+ sourceId: sourceEntityId,
2961
+ approvedByUserId: Number(payload.approvedByUserId) || null,
2962
+ }),
2963
+ );
2964
+
2965
+ await this.integrationApi.publishEvent(
2966
+ {
2967
+ eventName: 'operations.contract.created',
2968
+ sourceModule: 'operations',
2969
+ aggregateType: 'contract',
2970
+ aggregateId: String(contractId),
2971
+ payload: this.buildContractCreatedEventPayload(
2972
+ {
2973
+ id: contractId,
2974
+ code: generatedCode,
2975
+ name: contractName,
2976
+ clientName,
2977
+ contractCategory,
2978
+ contractType,
2979
+ billingModel,
2980
+ originType: 'crm_proposal',
2981
+ originId: sourceEntityId,
2982
+ startDate: proposal.validFrom ?? null,
2983
+ endDate: proposal.validUntil ?? null,
2984
+ description: this.normalizeOptionalText(proposal.notes),
2985
+ financialTerms,
2986
+ },
2987
+ payload,
2988
+ String(payload.locale || '').trim() || 'en',
2989
+ Number(payload.approvedByUserId) || undefined,
2990
+ ),
2991
+ metadata: {
2992
+ producer: 'operations',
2993
+ correlationId:
2994
+ payload.correlationId || `proposal:${sourceEntityId}`,
2995
+ sourceModule: 'operations',
2996
+ sourceEntity: 'contract',
2997
+ sourceId: String(contractId),
2998
+ originType: 'crm_proposal',
2999
+ originId: sourceEntityId,
3000
+ },
3001
+ },
3002
+ {
3003
+ persistenceClient: tx as any,
3004
+ },
3005
+ );
3006
+
3007
+ return {
3008
+ id: contractId,
3009
+ code: generatedCode,
3010
+ };
3011
+ });
3012
+ }
3013
+
3014
+ private buildContractCreatedEventPayload(
3015
+ contract: {
3016
+ id: number;
3017
+ code?: string | null;
3018
+ name?: string | null;
3019
+ clientName?: string | null;
3020
+ contractCategory?: ContractPayload['contractCategory'];
3021
+ contractType?: ContractPayload['contractType'];
3022
+ billingModel?: ContractPayload['billingModel'];
3023
+ originType?: ContractPayload['originType'];
3024
+ originId?: string | number | null;
3025
+ startDate?: string | null;
3026
+ endDate?: string | null;
3027
+ description?: string | null;
3028
+ financialTerms?: ContractPayload['financialTerms'];
3029
+ },
3030
+ payload: ProposalApprovedEventPayload,
3031
+ locale = 'en',
3032
+ createdByUserId?: number,
3033
+ ) {
3034
+ return {
3035
+ contractId: contract.id,
3036
+ proposalId: payload.proposalId ?? null,
3037
+ proposalRevisionId: payload.proposalRevisionId ?? null,
3038
+ personId: payload.personId ?? payload.person?.id ?? null,
3039
+ createdByUserId: createdByUserId ?? null,
3040
+ createdAt: new Date().toISOString(),
3041
+ locale,
3042
+ correlationId:
3043
+ payload.correlationId || `proposal:${payload.proposalId ?? contract.originId ?? contract.id}`,
3044
+ sourceModule: 'operations',
3045
+ sourceEntity: 'contract',
3046
+ sourceId: String(contract.id),
3047
+ source_module: 'operations',
3048
+ source_entity: 'contract',
3049
+ source_id: String(contract.id),
3050
+ contract: {
3051
+ id: contract.id,
3052
+ code: contract.code ?? null,
3053
+ name: contract.name ?? null,
3054
+ clientName: contract.clientName ?? null,
3055
+ status: 'draft',
3056
+ contractCategory: contract.contractCategory ?? 'client',
3057
+ contractType: contract.contractType ?? 'service_agreement',
3058
+ billingModel: contract.billingModel ?? 'fixed_price',
3059
+ originType: contract.originType ?? 'manual',
3060
+ originId: contract.originId ?? null,
3061
+ startDate: contract.startDate ?? null,
3062
+ endDate: contract.endDate ?? null,
3063
+ description: contract.description ?? null,
3064
+ financialTerms: contract.financialTerms ?? [],
3065
+ },
3066
+ proposal: payload.proposal ?? {
3067
+ code: payload.code ?? null,
3068
+ title: payload.title ?? null,
3069
+ totalAmount: payload.total ?? null,
3070
+ notes: payload.commercialTerms?.notes ?? null,
3071
+ },
3072
+ person: payload.person ?? null,
3073
+ };
3074
+ }
3075
+
3076
+ private async buildContractActivatedEventPayload(
3077
+ contract: {
3078
+ id: number;
3079
+ code?: string | null;
3080
+ name?: string | null;
3081
+ clientName?: string | null;
3082
+ contractCategory?: ContractPayload['contractCategory'];
3083
+ contractType?: ContractPayload['contractType'];
3084
+ billingModel?: ContractPayload['billingModel'];
3085
+ originType?: ContractPayload['originType'];
3086
+ originId?: string | number | null;
3087
+ startDate?: string | null;
3088
+ endDate?: string | null;
3089
+ signedAt?: string | null;
3090
+ effectiveDate?: string | null;
3091
+ budgetAmount?: number | null;
3092
+ description?: string | null;
3093
+ financialTerms?: ContractPayload['financialTerms'];
3094
+ parties?: ContractPayload['parties'];
3095
+ },
3096
+ locale = 'en',
3097
+ activatedByUserId?: number,
3098
+ ) {
3099
+ let personId: number | null = null;
3100
+ let proposalId: number | null = null;
3101
+ let personRecord: any = null;
3102
+
3103
+ if (contract.originType === 'crm_proposal' && contract.originId) {
3104
+ const parsedProposalId = Number(contract.originId);
3105
+ if (Number.isInteger(parsedProposalId) && parsedProposalId > 0) {
3106
+ proposalId = parsedProposalId;
3107
+ const proposal = await (this.prisma as any).proposal.findUnique({
3108
+ where: { id: parsedProposalId },
3109
+ include: {
3110
+ person: {
3111
+ select: {
3112
+ id: true,
3113
+ name: true,
3114
+ },
3115
+ },
3116
+ },
3117
+ });
3118
+
3119
+ if (proposal) {
3120
+ personId = proposal.person_id ?? proposal.person?.id ?? null;
3121
+
3122
+ const [companyRow, contactRows, documentRows] =
3123
+ personId != null
3124
+ ? await Promise.all([
3125
+ (this.prisma as any).person_company.findUnique({
3126
+ where: { id: personId },
3127
+ select: { trade_name: true },
3128
+ }),
3129
+ (this.prisma as any).contact.findMany({
3130
+ where: { person_id: personId },
3131
+ include: {
3132
+ contact_type: {
3133
+ select: { code: true },
3134
+ },
3135
+ },
3136
+ orderBy: [{ is_primary: 'desc' }, { id: 'asc' }],
3137
+ }),
3138
+ (this.prisma as any).document.findMany({
3139
+ where: { person_id: personId },
3140
+ select: { value: true },
3141
+ orderBy: [{ id: 'asc' }],
3142
+ }),
3143
+ ])
3144
+ : [null, [], []];
3145
+
3146
+ const resolveContactValue = (codes: string[]) => {
3147
+ const normalizedCodes = new Set(codes.map((code) => code.toUpperCase()));
3148
+ const items = Array.isArray(contactRows)
3149
+ ? contactRows.filter((contact: any) =>
3150
+ normalizedCodes.has(
3151
+ String(contact?.contact_type?.code || '').toUpperCase(),
3152
+ ),
3153
+ )
3154
+ : [];
3155
+ const primary = items.find((contact: any) => contact?.is_primary);
3156
+ const fallback = items[0];
3157
+ return primary?.value ?? fallback?.value ?? null;
3158
+ };
3159
+
3160
+ personRecord = proposal.person
3161
+ ? {
3162
+ ...proposal.person,
3163
+ trade_name: companyRow?.trade_name ?? null,
3164
+ email: resolveContactValue(['EMAIL']),
3165
+ phone: resolveContactValue(['PHONE', 'MOBILE', 'WHATSAPP']),
3166
+ document: documentRows[0]?.value ?? null,
3167
+ }
3168
+ : null;
3169
+ }
3170
+ }
3171
+ }
3172
+
3173
+ const financialTerms = (contract.financialTerms ?? []).map((term) => ({
3174
+ label: term.label,
3175
+ termType: term.termType ?? 'value',
3176
+ amount: Number(term.amount ?? 0),
3177
+ recurrence: term.recurrence ?? 'one_time',
3178
+ dueDay: term.dueDay ?? null,
3179
+ notes: term.notes ?? null,
3180
+ }));
3181
+
3182
+ const totalAmount =
3183
+ financialTerms.reduce(
3184
+ (sum, term) => sum + (Number.isFinite(term.amount) ? term.amount : 0),
3185
+ 0,
3186
+ ) || Number(contract.budgetAmount ?? 0);
3187
+
3188
+ return {
3189
+ contractId: contract.id,
3190
+ proposalId,
3191
+ personId,
3192
+ activatedByUserId: activatedByUserId ?? null,
3193
+ signedByUserId: activatedByUserId ?? null,
3194
+ activatedAt: new Date().toISOString(),
3195
+ signedAt: contract.signedAt ?? contract.effectiveDate ?? contract.startDate ?? null,
3196
+ locale,
3197
+ correlationId: proposalId ? `proposal:${proposalId}` : `contract:${contract.id}`,
3198
+ sourceModule: 'operations',
3199
+ sourceEntity: 'contract',
3200
+ sourceId: String(contract.id),
3201
+ source_module: 'operations',
3202
+ source_entity: 'contract',
3203
+ source_id: String(contract.id),
3204
+ contract: {
3205
+ code: contract.code ?? null,
3206
+ name: contract.name ?? null,
3207
+ clientName: contract.clientName ?? null,
3208
+ contractCategory: contract.contractCategory ?? 'client',
3209
+ contractType: contract.contractType ?? 'service_agreement',
3210
+ billingModel: contract.billingModel ?? 'fixed_price',
3211
+ originType: contract.originType ?? 'manual',
3212
+ originId: contract.originId ?? null,
3213
+ startDate: contract.startDate ?? null,
3214
+ endDate: contract.endDate ?? null,
3215
+ signedAt: contract.signedAt ?? null,
3216
+ effectiveDate: contract.effectiveDate ?? null,
3217
+ budgetAmount: contract.budgetAmount ?? null,
3218
+ description: contract.description ?? null,
3219
+ financialTerms,
3220
+ parties: contract.parties ?? [],
3221
+ },
3222
+ person: personRecord
3223
+ ? {
3224
+ id: personRecord.id ?? null,
3225
+ name: personRecord.name ?? null,
3226
+ tradeName: personRecord.trade_name ?? null,
3227
+ email: personRecord.email ?? null,
3228
+ phone: personRecord.phone ?? null,
3229
+ document: personRecord.document ?? null,
3230
+ }
3231
+ : null,
3232
+ receivable: {
3233
+ personId,
3234
+ documentNumber: contract.code ?? `CONTRACT-${contract.id}`,
3235
+ totalAmount,
3236
+ description:
3237
+ contract.description ?? contract.name ?? contract.code ?? null,
3238
+ },
3239
+ };
3240
+ }
3241
+
3242
+ async updateContract(userId: number, contractId: number, data: Partial<ContractPayload>) {
3243
+ const actor = await this.getActorContext(userId);
3244
+ this.ensureDirector(actor);
3245
+ const current = await this.getContractById(userId, contractId);
3246
+ const nextStatus = this.normalizeEnumValue(
3247
+ data.status,
3248
+ CONTRACT_STATUS_VALUES,
3249
+ current.status ?? 'draft',
3250
+ );
3251
+ const nextSignatureStatus = this.normalizeEnumValue(
3252
+ data.signatureStatus,
3253
+ SIGNATURE_STATUS_VALUES,
3254
+ current.signatureStatus ?? 'not_started',
3255
+ );
3256
+ const shouldPublishSigned =
3257
+ current.signatureStatus !== 'signed' && nextSignatureStatus === 'signed';
3258
+ const shouldPublishActivation =
3259
+ current.status !== 'active' && nextStatus === 'active';
3260
+
3261
+ const updates: string[] = [];
3262
+ const params: unknown[] = [];
3263
+ this.pushUpdate(updates, params, 'code', data.code);
3264
+ this.pushUpdate(updates, params, 'name', data.name);
3265
+ this.pushUpdate(
3266
+ updates,
3267
+ params,
3268
+ 'contract_category',
3269
+ data.contractCategory,
3270
+ 'operations_contract_contract_category_70d553ea09_enum'
3271
+ );
3272
+ this.pushUpdate(
3273
+ updates,
3274
+ params,
3275
+ 'contract_type',
3276
+ data.contractType,
3277
+ 'operations_contract_contract_type_48331e2ebf_enum'
3278
+ );
3279
+ this.pushUpdate(updates, params, 'client_name', data.clientName);
3280
+ this.pushUpdate(
3281
+ updates,
3282
+ params,
3283
+ 'signature_status',
3284
+ data.signatureStatus,
3285
+ 'operations_contract_signature_status_2cb7282a7b_enum'
3286
+ );
3287
+ this.pushUpdate(updates, params, 'is_active', data.isActive);
3288
+ this.pushUpdate(
3289
+ updates,
3290
+ params,
3291
+ 'billing_model',
3292
+ data.billingModel,
3293
+ 'operations_contract_billing_model_409dc7fea2_enum'
3294
+ );
3295
+ this.pushUpdate(updates, params, 'account_manager_collaborator_id', data.accountManagerCollaboratorId);
3296
+ this.pushUpdate(updates, params, 'related_collaborator_id', data.relatedCollaboratorId);
3297
+ this.pushUpdate(updates, params, 'contract_template_id', data.contractTemplateId);
3298
+ this.pushUpdate(
3299
+ updates,
3300
+ params,
3301
+ 'origin_type',
3302
+ data.originType,
3303
+ 'operations_contract_origin_type_07a7cc2b5d_enum'
3304
+ );
3305
+ this.pushUpdate(updates, params, 'origin_id', data.originId);
3306
+ this.pushUpdate(updates, params, 'start_date', data.startDate, 'date');
3307
+ this.pushUpdate(updates, params, 'end_date', data.endDate, 'date');
3308
+ this.pushUpdate(updates, params, 'signed_at', data.signedAt, 'date');
3309
+ this.pushUpdate(updates, params, 'effective_date', data.effectiveDate, 'date');
3310
+ this.pushUpdate(updates, params, 'budget_amount', data.budgetAmount);
3311
+ this.pushUpdate(updates, params, 'monthly_hour_cap', data.monthlyHourCap);
3312
+ this.pushUpdate(
3313
+ updates,
3314
+ params,
3315
+ 'status',
3316
+ data.status,
3317
+ 'operations_contract_status_a0395962df_enum'
3318
+ );
3319
+ this.pushUpdate(
3320
+ updates,
3321
+ params,
3322
+ 'creation_mode',
3323
+ data.creationMode,
3324
+ 'operations_contract_creation_mode_98ba669209_enum'
3325
+ );
3326
+ this.pushUpdate(updates, params, 'wizard_step', data.wizardStep);
3327
+ this.pushUpdate(updates, params, 'description', data.description);
3328
+ this.pushUpdate(updates, params, 'content_html', data.contentHtml);
3329
+
3330
+ await this.prisma.$transaction(async (tx) => {
3331
+ if (updates.length) {
3332
+ params.push(contractId);
3333
+ await (tx as any).$executeRawUnsafe(
3334
+ `UPDATE operations_contract
3335
+ SET ${updates.join(', ')},
3336
+ updated_at = NOW()
3337
+ WHERE id = $${params.length}`,
3338
+ ...params
3339
+ );
3340
+ }
3341
+
3342
+ if (data.parties) {
3343
+ await this.replaceContractParties(tx as any, contractId, data.parties);
3344
+ }
3345
+ if (data.signatures) {
3346
+ await this.replaceContractSignatures(
3347
+ tx as any,
3348
+ contractId,
3349
+ data.signatures
3350
+ );
3351
+ }
3352
+ if (data.financialTerms) {
3353
+ await this.replaceContractFinancialTerms(
3354
+ tx as any,
3355
+ contractId,
3356
+ data.financialTerms
3357
+ );
3358
+ }
3359
+ if (data.revisions) {
3360
+ await this.replaceContractRevisions(
3361
+ tx as any,
3362
+ contractId,
3363
+ data.revisions
3364
+ );
3365
+ }
3366
+ if (data.replaceUploadedPdfDocument) {
3367
+ await this.replaceContractDocument(
3368
+ tx as any,
3369
+ contractId,
3370
+ 'source_upload',
3371
+ data.replaceUploadedPdfDocument
3372
+ );
3373
+ }
3374
+
3375
+ await this.insertContractHistory(
3376
+ tx as any,
3377
+ contractId,
3378
+ userId,
3379
+ 'updated',
3380
+ 'Contract registry data updated.'
3381
+ );
3382
+
3383
+ if (shouldPublishSigned || shouldPublishActivation) {
3384
+ const contractEventPayload = await this.buildContractActivatedEventPayload(
3385
+ {
3386
+ id: current.id,
3387
+ code: data.code ?? current.code,
3388
+ name: data.name ?? current.name,
3389
+ clientName: data.clientName ?? current.clientName,
3390
+ contractCategory: data.contractCategory ?? current.contractCategory,
3391
+ contractType: data.contractType ?? current.contractType,
3392
+ billingModel: data.billingModel ?? current.billingModel,
3393
+ originType: data.originType ?? current.originType,
3394
+ originId: data.originId ?? current.originId,
3395
+ startDate: data.startDate ?? current.startDate,
3396
+ endDate: data.endDate ?? current.endDate,
3397
+ signedAt: data.signedAt ?? current.signedAt,
3398
+ effectiveDate: data.effectiveDate ?? current.effectiveDate,
3399
+ budgetAmount: data.budgetAmount ?? current.budgetAmount,
3400
+ description: data.description ?? current.description,
3401
+ financialTerms: data.financialTerms ?? current.financialTerms,
3402
+ parties: data.parties ?? current.parties,
3403
+ },
3404
+ 'en',
3405
+ userId,
3406
+ );
3407
+
3408
+ if (shouldPublishSigned) {
3409
+ await this.integrationApi.publishEvent(
3410
+ {
3411
+ eventName: 'operations.contract.signed',
3412
+ sourceModule: 'operations',
3413
+ aggregateType: 'contract',
3414
+ aggregateId: String(contractId),
3415
+ payload: {
3416
+ ...contractEventPayload,
3417
+ signedByUserId: userId ?? null,
3418
+ signedAt:
3419
+ data.signedAt ??
3420
+ current.signedAt ??
3421
+ contractEventPayload.signedAt ??
3422
+ new Date().toISOString(),
3423
+ },
3424
+ metadata: {
3425
+ producer: 'operations',
3426
+ correlationId:
3427
+ contractEventPayload.correlationId || `contract:${contractId}`,
3428
+ sourceModule: 'operations',
3429
+ sourceEntity: 'contract',
3430
+ sourceId: String(contractId),
3431
+ originType: data.originType ?? current.originType,
3432
+ originId: data.originId ?? current.originId,
3433
+ lifecycle: 'signed',
3434
+ },
3435
+ },
3436
+ {
3437
+ persistenceClient: tx as any,
3438
+ },
3439
+ );
3440
+ }
3441
+
3442
+ if (shouldPublishActivation) {
3443
+ await this.integrationApi.publishEvent(
3444
+ {
3445
+ eventName: 'operations.contract.activated',
3446
+ sourceModule: 'operations',
3447
+ aggregateType: 'contract',
3448
+ aggregateId: String(contractId),
3449
+ payload: contractEventPayload,
3450
+ metadata: {
3451
+ producer: 'operations',
3452
+ correlationId:
3453
+ contractEventPayload.correlationId || `contract:${contractId}`,
3454
+ sourceModule: 'operations',
3455
+ sourceEntity: 'contract',
3456
+ sourceId: String(contractId),
3457
+ originType: data.originType ?? current.originType,
3458
+ originId: data.originId ?? current.originId,
3459
+ lifecycle: 'activated',
3460
+ },
3461
+ },
3462
+ {
3463
+ persistenceClient: tx as any,
3464
+ },
3465
+ );
3466
+ }
3467
+ }
3468
+ });
3469
+
3470
+ return this.getContractById(userId, contractId);
3471
+ }
3472
+
3473
+ async removeContract(userId: number, contractId: number) {
3474
+ const actor = await this.getActorContext(userId);
3475
+ this.ensureDirector(actor);
3476
+ const current = await this.getContractById(userId, contractId);
3477
+
3478
+ await this.prisma.$transaction(async (tx) => {
3479
+ await (tx as any).$executeRawUnsafe(
3480
+ `UPDATE operations_project
3481
+ SET contract_id = NULL,
3482
+ updated_at = NOW()
3483
+ WHERE contract_id = $1
3484
+ AND deleted_at IS NULL`,
3485
+ contractId
3486
+ );
3487
+
3488
+ for (const tableName of [
3489
+ 'operations_contract_party',
3490
+ 'operations_contract_signature',
3491
+ 'operations_contract_financial_term',
3492
+ 'operations_contract_document',
3493
+ 'operations_contract_revision',
3494
+ ]) {
3495
+ await (tx as any).$executeRawUnsafe(
3496
+ `UPDATE ${tableName}
3497
+ SET deleted_at = NOW(),
3498
+ updated_at = NOW()
3499
+ WHERE contract_id = $1
3500
+ AND deleted_at IS NULL`,
3501
+ contractId
3502
+ );
3503
+ }
3504
+
3505
+ await this.insertContractHistory(
3506
+ tx as any,
3507
+ contractId,
3508
+ userId,
3509
+ 'deleted',
3510
+ `Contract "${current.name ?? current.code ?? `#${contractId}`}" removed from registry.`
3511
+ );
3512
+
3513
+ await (tx as any).$executeRawUnsafe(
3514
+ `UPDATE operations_contract
3515
+ SET deleted_at = NOW(),
3516
+ is_active = false,
3517
+ status = 'archived',
3518
+ updated_at = NOW()
3519
+ WHERE id = $1
3520
+ AND deleted_at IS NULL`,
3521
+ contractId
3522
+ );
3523
+ });
3524
+
3525
+ return { success: true };
3526
+ }
3527
+
3528
+ async listTimesheets(userId: number) {
3529
+ const actor = await this.getActorContext(userId);
3530
+ const filter = this.buildIdFilter(actor.visibleCollaboratorIds, 't.collaborator_id', actor.isDirector);
1454
3531
 
1455
3532
  const headers = await this.queryRows<{
1456
3533
  id: number;
@@ -1563,7 +3640,7 @@ export class OperationsService {
1563
3640
  status,
1564
3641
  created_at,
1565
3642
  updated_at
1566
- ) VALUES ($1, $2, $3, $4, $5, 'draft', NOW(), NOW())
3643
+ ) VALUES ($1, $2, $3::date, $4::date, $5, 'draft', NOW(), NOW())
1567
3644
  RETURNING id`,
1568
3645
  collaboratorId,
1569
3646
  collaborator.supervisorId ?? null,
@@ -1603,8 +3680,8 @@ export class OperationsService {
1603
3680
  await this.prisma.$transaction(async (tx) => {
1604
3681
  const updates: string[] = [];
1605
3682
  const params: unknown[] = [];
1606
- this.pushUpdate(updates, params, 'week_start_date', data.weekStartDate);
1607
- this.pushUpdate(updates, params, 'week_end_date', data.weekEndDate);
3683
+ this.pushUpdate(updates, params, 'week_start_date', data.weekStartDate, 'date');
3684
+ this.pushUpdate(updates, params, 'week_end_date', data.weekEndDate, 'date');
1608
3685
  this.pushUpdate(updates, params, 'notes', data.notes);
1609
3686
 
1610
3687
  if (updates.length) {
@@ -1732,7 +3809,17 @@ export class OperationsService {
1732
3809
  submitted_at,
1733
3810
  created_at,
1734
3811
  updated_at
1735
- ) VALUES ($1, $2, COALESCE($3, 'vacation'), $4, $5, $6, 'submitted', $7, NOW(), NOW(), NOW())
3812
+ ) VALUES (
3813
+ $1,
3814
+ $2,
3815
+ $3::operations_time_off_request_request_type_fdcf258f8e_enum,
3816
+ $4::date,
3817
+ $5::date,
3818
+ $6,
3819
+ 'submitted',
3820
+ $7,
3821
+ NOW(), NOW(), NOW()
3822
+ )
1736
3823
  RETURNING id`,
1737
3824
  collaboratorId,
1738
3825
  collaborator.supervisorId ?? null,
@@ -1909,7 +3996,14 @@ export class OperationsService {
1909
3996
  created_at,
1910
3997
  updated_at
1911
3998
  ) VALUES (
1912
- $1, $2, COALESCE($3, 'temporary'), $4, $5, 'submitted', $6, NOW(), NOW(), NOW()
3999
+ $1,
4000
+ $2,
4001
+ $3::operations_schedule_adjustment_request_request__d3f1202fa6_enum,
4002
+ $4::date,
4003
+ $5::date,
4004
+ 'submitted',
4005
+ $6,
4006
+ NOW(), NOW(), NOW()
1913
4007
  )
1914
4008
  RETURNING id`,
1915
4009
  collaboratorId,
@@ -1932,7 +4026,11 @@ export class OperationsService {
1932
4026
  break_minutes,
1933
4027
  created_at,
1934
4028
  updated_at
1935
- ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
4029
+ ) VALUES (
4030
+ $1,
4031
+ $2::operations_schedule_adjustment_day_weekday_ba6542bf86_enum,
4032
+ $3, $4::time, $5::time, $6, NOW(), NOW()
4033
+ )`,
1936
4034
  requestId,
1937
4035
  day.weekday,
1938
4036
  day.isWorkingDay ?? true,
@@ -2151,7 +4249,7 @@ export class OperationsService {
2151
4249
  await this.prisma.$transaction(async (tx) => {
2152
4250
  await (tx as any).$executeRawUnsafe(
2153
4251
  `UPDATE operations_approval
2154
- SET status = $1,
4252
+ SET status = $1::operations_approval_status_747aa313d9_enum,
2155
4253
  decided_at = NOW(),
2156
4254
  decision_note = $2,
2157
4255
  updated_at = NOW()
@@ -2164,7 +4262,7 @@ export class OperationsService {
2164
4262
  if (approval.targetType === 'timesheet') {
2165
4263
  await (tx as any).$executeRawUnsafe(
2166
4264
  `UPDATE operations_timesheet
2167
- SET status = $1,
4265
+ SET status = $1::operations_timesheet_status_128a8ecd30_enum,
2168
4266
  reviewed_at = NOW(),
2169
4267
  approver_collaborator_id = $2,
2170
4268
  updated_at = NOW()
@@ -2178,7 +4276,7 @@ export class OperationsService {
2178
4276
  if (approval.targetType === 'time_off_request') {
2179
4277
  await (tx as any).$executeRawUnsafe(
2180
4278
  `UPDATE operations_time_off_request
2181
- SET status = $1,
4279
+ SET status = $1::operations_time_off_request_status_c0b28799ee_enum,
2182
4280
  reviewed_at = NOW(),
2183
4281
  approver_collaborator_id = $2,
2184
4282
  submitted_at = COALESCE(submitted_at, NOW()),
@@ -2193,7 +4291,7 @@ export class OperationsService {
2193
4291
  if (approval.targetType === 'schedule_adjustment_request') {
2194
4292
  await (tx as any).$executeRawUnsafe(
2195
4293
  `UPDATE operations_schedule_adjustment_request
2196
- SET status = $1,
4294
+ SET status = $1::operations_schedule_adjustment_request_status_159e10f34e_enum,
2197
4295
  reviewed_at = NOW(),
2198
4296
  approver_collaborator_id = $2,
2199
4297
  submitted_at = COALESCE(submitted_at, NOW()),
@@ -2294,10 +4392,12 @@ export class OperationsService {
2294
4392
  supervisorName: string | null;
2295
4393
  }>(
2296
4394
  `SELECT c.id,
2297
- c.display_name AS "displayName",
4395
+ COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
2298
4396
  s.id AS "supervisorId",
2299
4397
  s.display_name AS "supervisorName"
2300
4398
  FROM operations_collaborator c
4399
+ LEFT JOIN person person_record
4400
+ ON person_record.id = c.person_id
2301
4401
  LEFT JOIN operations_collaborator s
2302
4402
  ON s.id = c.supervisor_collaborator_id
2303
4403
  WHERE c.user_id = $1
@@ -2314,10 +4414,12 @@ export class OperationsService {
2314
4414
  supervisorName: string | null;
2315
4415
  }>(
2316
4416
  `SELECT c.id,
2317
- c.display_name AS "displayName",
4417
+ COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
2318
4418
  s.id AS "supervisorId",
2319
4419
  s.display_name AS "supervisorName"
2320
4420
  FROM operations_collaborator c
4421
+ LEFT JOIN person person_record
4422
+ ON person_record.id = c.person_id
2321
4423
  LEFT JOIN operations_collaborator s
2322
4424
  ON s.id = c.supervisor_collaborator_id
2323
4425
  WHERE c.id = $1
@@ -2330,6 +4432,361 @@ export class OperationsService {
2330
4432
  return collaborator;
2331
4433
  }
2332
4434
 
4435
+ private async getPersonById(personId: number) {
4436
+ const person = await this.querySingle<{
4437
+ id: number;
4438
+ name: string;
4439
+ type: 'individual' | 'company' | null;
4440
+ }>(
4441
+ `SELECT id, name, type
4442
+ FROM person
4443
+ WHERE id = $1`,
4444
+ [personId]
4445
+ );
4446
+
4447
+ if (!person) {
4448
+ throw new NotFoundException('Person not found.');
4449
+ }
4450
+
4451
+ return person;
4452
+ }
4453
+
4454
+ private async getDepartmentById(
4455
+ client: any,
4456
+ departmentId: number,
4457
+ includeInactive = false
4458
+ ) {
4459
+ const departments = (await client.$queryRawUnsafe(
4460
+ `SELECT id,
4461
+ slug,
4462
+ code,
4463
+ name,
4464
+ description,
4465
+ created_at AS "createdAt",
4466
+ updated_at AS "updatedAt",
4467
+ deleted_at AS "deletedAt",
4468
+ CASE WHEN deleted_at IS NULL THEN 'active' ELSE 'inactive' END AS status
4469
+ FROM operations_department
4470
+ WHERE id = $1
4471
+ AND ($2::boolean OR deleted_at IS NULL)`,
4472
+ departmentId,
4473
+ includeInactive
4474
+ )) as Array<{
4475
+ id: number;
4476
+ slug: string;
4477
+ code: string | null;
4478
+ name: string;
4479
+ description: string | null;
4480
+ createdAt: string | null;
4481
+ updatedAt: string | null;
4482
+ deletedAt: string | null;
4483
+ status: 'active' | 'inactive';
4484
+ }>;
4485
+
4486
+ const department = departments[0] ?? null;
4487
+ if (!department) {
4488
+ throw new NotFoundException('Department not found.');
4489
+ }
4490
+
4491
+ return department;
4492
+ }
4493
+
4494
+ private async assertDepartmentNameAvailable(
4495
+ client: any,
4496
+ name: string,
4497
+ excludeDepartmentId?: number | null
4498
+ ) {
4499
+ const existing = (await client.$queryRawUnsafe(
4500
+ `SELECT id
4501
+ FROM operations_department
4502
+ WHERE LOWER(name) = LOWER($1)
4503
+ AND ($2::int IS NULL OR id <> $2)
4504
+ LIMIT 1`,
4505
+ name,
4506
+ excludeDepartmentId ?? null
4507
+ )) as { id: number }[];
4508
+
4509
+ if (existing[0]) {
4510
+ throw new BadRequestException('A department with this name already exists.');
4511
+ }
4512
+ }
4513
+
4514
+ private async assertDepartmentCodeAvailable(
4515
+ client: any,
4516
+ code: string,
4517
+ excludeDepartmentId?: number | null
4518
+ ) {
4519
+ const existing = (await client.$queryRawUnsafe(
4520
+ `SELECT id
4521
+ FROM operations_department
4522
+ WHERE UPPER(COALESCE(code, '')) = UPPER($1)
4523
+ AND ($2::int IS NULL OR id <> $2)
4524
+ LIMIT 1`,
4525
+ code,
4526
+ excludeDepartmentId ?? null
4527
+ )) as { id: number }[];
4528
+
4529
+ if (existing[0]) {
4530
+ throw new BadRequestException('A department with this code already exists.');
4531
+ }
4532
+ }
4533
+
4534
+ private async resolveDepartmentReference(
4535
+ client: any,
4536
+ input: {
4537
+ departmentId?: number | null;
4538
+ departmentName?: string | null;
4539
+ }
4540
+ ) {
4541
+ if (input.departmentName !== undefined) {
4542
+ const normalizedDepartmentName = this.normalizeOptionalText(
4543
+ input.departmentName
4544
+ );
4545
+
4546
+ if (!normalizedDepartmentName) {
4547
+ return null;
4548
+ }
4549
+
4550
+ const existingByName = (await client.$queryRawUnsafe(
4551
+ `SELECT id, name
4552
+ FROM operations_department
4553
+ WHERE deleted_at IS NULL
4554
+ AND LOWER(name) = LOWER($1)
4555
+ ORDER BY id ASC
4556
+ LIMIT 1`,
4557
+ normalizedDepartmentName
4558
+ )) as { id: number; name: string }[];
4559
+
4560
+ if (existingByName[0]) {
4561
+ return existingByName[0];
4562
+ }
4563
+
4564
+ const slug = await this.generateUniqueDepartmentSlug(
4565
+ client,
4566
+ normalizedDepartmentName
4567
+ );
4568
+ const created = (await client.$queryRawUnsafe(
4569
+ `INSERT INTO operations_department (
4570
+ slug,
4571
+ code,
4572
+ name,
4573
+ description,
4574
+ created_at,
4575
+ updated_at
4576
+ ) VALUES ($1, NULL, $2, NULL, NOW(), NOW())
4577
+ RETURNING id, name`,
4578
+ slug,
4579
+ normalizedDepartmentName
4580
+ )) as { id: number; name: string }[];
4581
+
4582
+ return created[0] ?? null;
4583
+ }
4584
+
4585
+ if (
4586
+ typeof input.departmentId === 'number' &&
4587
+ Number.isInteger(input.departmentId) &&
4588
+ input.departmentId > 0
4589
+ ) {
4590
+ return this.getDepartmentById(client, input.departmentId);
4591
+ }
4592
+
4593
+ return null;
4594
+ }
4595
+
4596
+ private async generateUniqueDepartmentSlug(
4597
+ client: any,
4598
+ label: string,
4599
+ excludeDepartmentId?: number | null
4600
+ ) {
4601
+ const baseSlug =
4602
+ this.slugifyValue(label) || `department-${Date.now().toString(36)}`;
4603
+
4604
+ for (let attempt = 0; attempt < 25; attempt += 1) {
4605
+ const candidate =
4606
+ attempt === 0 ? baseSlug : `${baseSlug}-${attempt + 1}`;
4607
+ const existing = (await client.$queryRawUnsafe(
4608
+ `SELECT id
4609
+ FROM operations_department
4610
+ WHERE slug = $1
4611
+ AND ($2::int IS NULL OR id <> $2)
4612
+ LIMIT 1`,
4613
+ candidate,
4614
+ excludeDepartmentId ?? null
4615
+ )) as { id: number }[];
4616
+
4617
+ if (!existing.length) {
4618
+ return candidate;
4619
+ }
4620
+ }
4621
+
4622
+ return `${baseSlug}-${Date.now().toString(36)}`;
4623
+ }
4624
+
4625
+ private async getContractTemplateRecord(
4626
+ client: any,
4627
+ templateId: number,
4628
+ includeInactive = false
4629
+ ): Promise<{
4630
+ id: number;
4631
+ slug: string;
4632
+ code: string | null;
4633
+ name: string;
4634
+ description: string | null;
4635
+ contractCategory: string | null;
4636
+ contractType: string | null;
4637
+ billingModel: string | null;
4638
+ signatureStatus: string | null;
4639
+ isActive: boolean;
4640
+ status: string | null;
4641
+ contentHtml: string | null;
4642
+ usageCount: number;
4643
+ createdAt: Date;
4644
+ updatedAt: Date;
4645
+ }> {
4646
+ const template = (await client.$queryRawUnsafe(
4647
+ `SELECT t.id,
4648
+ t.slug,
4649
+ t.code,
4650
+ t.name,
4651
+ t.description,
4652
+ t.contract_category AS "contractCategory",
4653
+ t.contract_type AS "contractType",
4654
+ t.billing_model AS "billingModel",
4655
+ t.signature_status AS "signatureStatus",
4656
+ t.is_active AS "isActive",
4657
+ t.status,
4658
+ t.content_html AS "contentHtml",
4659
+ COUNT(DISTINCT c.id)::int AS "usageCount",
4660
+ t.created_at AS "createdAt",
4661
+ t.updated_at AS "updatedAt"
4662
+ FROM operations_contract_template t
4663
+ LEFT JOIN operations_contract c
4664
+ ON c.contract_template_id = t.id
4665
+ AND c.deleted_at IS NULL
4666
+ WHERE t.id = $1
4667
+ AND ($2::boolean = true OR t.deleted_at IS NULL)
4668
+ GROUP BY t.id
4669
+ LIMIT 1`,
4670
+ templateId,
4671
+ includeInactive
4672
+ )) as Array<{
4673
+ id: number;
4674
+ slug: string;
4675
+ code: string | null;
4676
+ name: string;
4677
+ description: string | null;
4678
+ contractCategory: string | null;
4679
+ contractType: string | null;
4680
+ billingModel: string | null;
4681
+ signatureStatus: string | null;
4682
+ isActive: boolean;
4683
+ status: string | null;
4684
+ contentHtml: string | null;
4685
+ usageCount: number;
4686
+ createdAt: Date;
4687
+ updatedAt: Date;
4688
+ }>;
4689
+
4690
+ const record = template[0];
4691
+
4692
+ if (!record) {
4693
+ throw new NotFoundException('Contract template not found.');
4694
+ }
4695
+
4696
+ return record;
4697
+ }
4698
+
4699
+ private async assertContractTemplateNameAvailable(
4700
+ client: any,
4701
+ name: string,
4702
+ excludeTemplateId?: number | null
4703
+ ) {
4704
+ const existing = (await client.$queryRawUnsafe(
4705
+ `SELECT id
4706
+ FROM operations_contract_template
4707
+ WHERE LOWER(name) = LOWER($1)
4708
+ AND deleted_at IS NULL
4709
+ AND ($2::int IS NULL OR id <> $2)
4710
+ LIMIT 1`,
4711
+ name,
4712
+ excludeTemplateId ?? null
4713
+ )) as { id: number }[];
4714
+
4715
+ if (existing[0]) {
4716
+ throw new BadRequestException(
4717
+ 'A contract template with this name already exists.'
4718
+ );
4719
+ }
4720
+ }
4721
+
4722
+ private async assertContractTemplateCodeAvailable(
4723
+ client: any,
4724
+ code: string,
4725
+ excludeTemplateId?: number | null
4726
+ ) {
4727
+ const existing = (await client.$queryRawUnsafe(
4728
+ `SELECT id
4729
+ FROM operations_contract_template
4730
+ WHERE UPPER(COALESCE(code, '')) = UPPER($1)
4731
+ AND deleted_at IS NULL
4732
+ AND ($2::int IS NULL OR id <> $2)
4733
+ LIMIT 1`,
4734
+ code,
4735
+ excludeTemplateId ?? null
4736
+ )) as { id: number }[];
4737
+
4738
+ if (existing[0]) {
4739
+ throw new BadRequestException(
4740
+ 'A contract template with this code already exists.'
4741
+ );
4742
+ }
4743
+ }
4744
+
4745
+ private async generateUniqueContractTemplateSlug(
4746
+ client: any,
4747
+ label: string,
4748
+ excludeTemplateId?: number | null
4749
+ ) {
4750
+ const baseSlug =
4751
+ this.slugifyValue(label) || `contract-template-${Date.now().toString(36)}`;
4752
+
4753
+ for (let attempt = 0; attempt < 25; attempt += 1) {
4754
+ const candidate =
4755
+ attempt === 0 ? baseSlug : `${baseSlug}-${attempt + 1}`;
4756
+ const existing = (await client.$queryRawUnsafe(
4757
+ `SELECT id
4758
+ FROM operations_contract_template
4759
+ WHERE slug = $1
4760
+ AND ($2::int IS NULL OR id <> $2)
4761
+ LIMIT 1`,
4762
+ candidate,
4763
+ excludeTemplateId ?? null
4764
+ )) as { id: number }[];
4765
+
4766
+ if (!existing.length) {
4767
+ return candidate;
4768
+ }
4769
+ }
4770
+
4771
+ return `${baseSlug}-${Date.now().toString(36)}`;
4772
+ }
4773
+
4774
+ private slugifyValue(value: string | null | undefined) {
4775
+ return (value ?? '')
4776
+ .normalize('NFKD')
4777
+ .replace(/[\u0300-\u036f]/g, '')
4778
+ .toLowerCase()
4779
+ .trim()
4780
+ .replace(/[^a-z0-9]+/g, '-')
4781
+ .replace(/^-+|-+$/g, '')
4782
+ .slice(0, 80);
4783
+ }
4784
+
4785
+ private normalizeOptionalText(value: string | null | undefined) {
4786
+ const normalized = String(value ?? '').trim();
4787
+ return normalized || null;
4788
+ }
4789
+
2333
4790
  private async getProjectDetails(
2334
4791
  projectId: number,
2335
4792
  actorCollaboratorId?: number | null
@@ -2521,10 +4978,14 @@ export class OperationsService {
2521
4978
  private async getCollaboratorDetails(collaboratorId: number) {
2522
4979
  const collaborator = await this.querySingle<{
2523
4980
  id: number;
2524
- userId: number;
4981
+ userId: number | null;
4982
+ personId: number | null;
4983
+ personName: string | null;
4984
+ personAvatarId: number | null;
2525
4985
  code: string;
2526
4986
  collaboratorType: string;
2527
4987
  displayName: string;
4988
+ departmentId: number | null;
2528
4989
  department: string | null;
2529
4990
  title: string | null;
2530
4991
  levelLabel: string | null;
@@ -2541,10 +5002,14 @@ export class OperationsService {
2541
5002
  }>(
2542
5003
  `SELECT c.id,
2543
5004
  c.user_id AS "userId",
5005
+ c.person_id AS "personId",
5006
+ person_record.name AS "personName",
5007
+ person_record.avatar_id AS "personAvatarId",
2544
5008
  c.code,
2545
5009
  c.collaborator_type AS "collaboratorType",
2546
- c.display_name AS "displayName",
2547
- c.department,
5010
+ COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
5011
+ c.department_id AS "departmentId",
5012
+ COALESCE(NULLIF(department_record.name, ''), NULLIF(c.department, '')) AS "department",
2548
5013
  c.title,
2549
5014
  c.level_label AS "levelLabel",
2550
5015
  c.weekly_capacity_hours AS "weeklyCapacityHours",
@@ -2558,6 +5023,11 @@ export class OperationsService {
2558
5023
  hiring_contract.status AS "contractStatus",
2559
5024
  COUNT(DISTINCT pa.id)::int AS "activeAssignments"
2560
5025
  FROM operations_collaborator c
5026
+ LEFT JOIN person person_record
5027
+ ON person_record.id = c.person_id
5028
+ LEFT JOIN operations_department department_record
5029
+ ON department_record.id = c.department_id
5030
+ AND department_record.deleted_at IS NULL
2561
5031
  LEFT JOIN operations_collaborator s
2562
5032
  ON s.id = c.supervisor_collaborator_id
2563
5033
  LEFT JOIN operations_project_assignment pa
@@ -2575,7 +5045,7 @@ export class OperationsService {
2575
5045
  ) hiring_contract ON TRUE
2576
5046
  WHERE c.id = $1
2577
5047
  AND c.deleted_at IS NULL
2578
- GROUP BY c.id, s.id, hiring_contract.id, hiring_contract.status`,
5048
+ GROUP BY c.id, person_record.id, department_record.id, s.id, hiring_contract.id, hiring_contract.status`,
2579
5049
  [collaboratorId]
2580
5050
  );
2581
5051
 
@@ -2799,7 +5269,7 @@ export class OperationsService {
2799
5269
  description,
2800
5270
  created_at,
2801
5271
  updated_at
2802
- ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
5272
+ ) VALUES ($1, $2, $3, $4::date, $5, $6, NOW(), NOW())`,
2803
5273
  timesheetId,
2804
5274
  entry.projectAssignmentId ?? null,
2805
5275
  entry.activityLabel ?? null,
@@ -2837,7 +5307,7 @@ export class OperationsService {
2837
5307
  const existing = (await client.$queryRawUnsafe(
2838
5308
  `SELECT id
2839
5309
  FROM operations_approval
2840
- WHERE target_type = $1
5310
+ WHERE target_type = $1::operations_approval_target_type_32d3f04385_enum
2841
5311
  AND target_id = $2
2842
5312
  AND deleted_at IS NULL`,
2843
5313
  input.targetType,
@@ -2856,7 +5326,10 @@ export class OperationsService {
2856
5326
  submitted_at,
2857
5327
  created_at,
2858
5328
  updated_at
2859
- ) VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW(), NOW())
5329
+ ) VALUES (
5330
+ $1::operations_approval_target_type_32d3f04385_enum,
5331
+ $2, $3, $4, 'pending', NOW(), NOW(), NOW()
5332
+ )
2860
5333
  RETURNING id`,
2861
5334
  input.targetType,
2862
5335
  input.targetId,
@@ -2924,7 +5397,13 @@ export class OperationsService {
2924
5397
  action,
2925
5398
  note,
2926
5399
  created_at
2927
- ) VALUES ($1, $2, $3, $4, NOW())`,
5400
+ ) VALUES (
5401
+ $1,
5402
+ $2,
5403
+ $3::operations_approval_history_action_c8478a05d6_enum,
5404
+ $4,
5405
+ NOW()
5406
+ )`,
2928
5407
  approvalId,
2929
5408
  actorCollaboratorId,
2930
5409
  action,
@@ -3061,7 +5540,7 @@ export class OperationsService {
3061
5540
  break_minutes,
3062
5541
  created_at,
3063
5542
  updated_at
3064
- ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
5543
+ ) VALUES ($1, $2::operations_collaborator_schedule_day_weekday_5d79528f6c_enum, $3, $4::time, $5::time, $6, NOW(), NOW())`,
3065
5544
  collaboratorId,
3066
5545
  day.weekday,
3067
5546
  day.isWorkingDay ?? true,
@@ -3101,7 +5580,9 @@ export class OperationsService {
3101
5580
  created_at,
3102
5581
  updated_at
3103
5582
  ) VALUES (
3104
- $1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, 'active'), NOW(), NOW()
5583
+ $1, $2, $3, $4, $5, $6, $7::date, $8::date,
5584
+ $9::operations_project_assignment_status_155b459bbf_enum,
5585
+ NOW(), NOW()
3105
5586
  )`,
3106
5587
  projectId,
3107
5588
  assignment.collaboratorId,
@@ -3204,8 +5685,13 @@ export class OperationsService {
3204
5685
  created_at,
3205
5686
  updated_at
3206
5687
  ) 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()
5688
+ $1, $2,
5689
+ $3::operations_contract_contract_category_70d553ea09_enum,
5690
+ $4::operations_contract_contract_type_48331e2ebf_enum,
5691
+ $5, 'not_started', true,
5692
+ $6::operations_contract_billing_model_409dc7fea2_enum,
5693
+ $7, $8, 'employee_hiring', $9, $10::date, NULL,
5694
+ NULL, $10::date, $11, $12, 'draft', $13, NULL, $14, $14, NOW(), NOW()
3209
5695
  )`,
3210
5696
  contractCode,
3211
5697
  contractName,
@@ -3231,6 +5717,7 @@ export class OperationsService {
3231
5717
  createdByUserId: number,
3232
5718
  input: {
3233
5719
  projectId: number;
5720
+ contractTemplateId: number | null;
3234
5721
  projectCode: string;
3235
5722
  projectName: string;
3236
5723
  clientName: string;
@@ -3248,6 +5735,83 @@ export class OperationsService {
3248
5735
  description: string | null;
3249
5736
  }
3250
5737
  ) {
5738
+ const templateRows = input.contractTemplateId
5739
+ ? ((await client.$queryRawUnsafe(
5740
+ `SELECT id,
5741
+ code,
5742
+ name,
5743
+ description,
5744
+ contract_category AS "contractCategory",
5745
+ contract_type AS "contractType",
5746
+ billing_model AS "billingModel",
5747
+ signature_status AS "signatureStatus",
5748
+ content_html AS "contentHtml"
5749
+ FROM operations_contract_template
5750
+ WHERE id = $1
5751
+ AND deleted_at IS NULL
5752
+ LIMIT 1`,
5753
+ input.contractTemplateId
5754
+ )) as Array<{
5755
+ id: number;
5756
+ code?: string | null;
5757
+ name?: string | null;
5758
+ description?: string | null;
5759
+ contractCategory?: string | null;
5760
+ contractType?: string | null;
5761
+ billingModel?: string | null;
5762
+ signatureStatus?: string | null;
5763
+ contentHtml?: string | null;
5764
+ }>)
5765
+ : [];
5766
+
5767
+ const selectedTemplate = templateRows[0] ?? null;
5768
+ const templateContext = {
5769
+ project_code: input.projectCode,
5770
+ project_name: input.projectName,
5771
+ client_name: input.clientName,
5772
+ start_date: input.startDate ?? '',
5773
+ end_date: input.endDate ?? '',
5774
+ budget_amount:
5775
+ input.budgetAmount !== null && input.budgetAmount !== undefined
5776
+ ? String(input.budgetAmount)
5777
+ : '',
5778
+ monthly_hour_cap:
5779
+ input.monthlyHourCap !== null && input.monthlyHourCap !== undefined
5780
+ ? String(input.monthlyHourCap)
5781
+ : '',
5782
+ };
5783
+
5784
+ const applyTemplateVariables = (value: string | null | undefined) => {
5785
+ const source = value ?? '';
5786
+ return Object.entries(templateContext).reduce(
5787
+ (result, [key, replacement]) =>
5788
+ result.split(`{{${key}}}`).join(replacement || ''),
5789
+ source
5790
+ );
5791
+ };
5792
+
5793
+ const templateCodePrefix = this.normalizeOptionalText(selectedTemplate?.code);
5794
+ const generatedContractCode = (
5795
+ this.normalizeOptionalText(input.contractCode) ??
5796
+ (templateCodePrefix
5797
+ ? `${templateCodePrefix}-${input.projectCode}`
5798
+ : null) ??
5799
+ `PRJ-${input.projectCode}`
5800
+ ).slice(0, 40);
5801
+
5802
+ const generatedContractName =
5803
+ this.normalizeOptionalText(input.contractName) ??
5804
+ this.normalizeOptionalText(applyTemplateVariables(selectedTemplate?.name)) ??
5805
+ `${input.projectName} Service Agreement`;
5806
+
5807
+ const generatedDescription = this.normalizeOptionalText(
5808
+ applyTemplateVariables(input.description ?? selectedTemplate?.description)
5809
+ );
5810
+
5811
+ const generatedContentHtml = this.normalizeOptionalText(
5812
+ applyTemplateVariables(selectedTemplate?.contentHtml)
5813
+ );
5814
+
3251
5815
  const created = await client.$queryRawUnsafe(
3252
5816
  `INSERT INTO operations_contract (
3253
5817
  code,
@@ -3260,6 +5824,7 @@ export class OperationsService {
3260
5824
  billing_model,
3261
5825
  account_manager_collaborator_id,
3262
5826
  related_collaborator_id,
5827
+ contract_template_id,
3263
5828
  origin_type,
3264
5829
  origin_id,
3265
5830
  start_date,
@@ -3276,21 +5841,50 @@ export class OperationsService {
3276
5841
  created_at,
3277
5842
  updated_at
3278
5843
  ) 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()
5844
+ $1,
5845
+ $2,
5846
+ $3::operations_contract_contract_category_70d553ea09_enum,
5847
+ $4::operations_contract_contract_type_48331e2ebf_enum,
5848
+ $5,
5849
+ $6::operations_contract_signature_status_2cb7282a7b_enum,
5850
+ true,
5851
+ $7::operations_contract_billing_model_409dc7fea2_enum,
5852
+ $8,
5853
+ NULL,
5854
+ $9,
5855
+ 'client_project',
5856
+ $10,
5857
+ $11::date,
5858
+ $12::date,
5859
+ NULL,
5860
+ $11::date,
5861
+ $13,
5862
+ $14,
5863
+ 'draft',
5864
+ $15,
5865
+ $16,
5866
+ $17,
5867
+ $17,
5868
+ NOW(),
5869
+ NOW()
3281
5870
  )
3282
5871
  RETURNING id`,
3283
- input.contractCode ?? `PRJ-${input.projectCode}`,
3284
- input.contractName ?? `${input.projectName} Service Agreement`,
5872
+ generatedContractCode,
5873
+ generatedContractName,
5874
+ selectedTemplate?.contractCategory ?? 'client',
5875
+ selectedTemplate?.contractType ?? 'service_agreement',
3285
5876
  input.clientName,
3286
- input.billingModel,
5877
+ selectedTemplate?.signatureStatus ?? 'not_started',
5878
+ selectedTemplate?.billingModel ?? input.billingModel,
3287
5879
  input.managerCollaboratorId,
5880
+ selectedTemplate?.id ?? null,
3288
5881
  input.projectId,
3289
5882
  input.startDate ?? new Date().toISOString().slice(0, 10),
3290
5883
  input.endDate ?? null,
3291
5884
  input.budgetAmount ?? null,
3292
5885
  input.monthlyHourCap ?? null,
3293
- input.description ?? null,
5886
+ generatedDescription,
5887
+ generatedContentHtml,
3294
5888
  createdByUserId
3295
5889
  );
3296
5890
 
@@ -3325,7 +5919,10 @@ export class OperationsService {
3325
5919
  created_at,
3326
5920
  updated_at
3327
5921
  ) VALUES (
3328
- $1, COALESCE($2, 'other'), COALESCE($3, 'company'), $4, $5, $6, $7, COALESCE($8, false), NOW(), NOW()
5922
+ $1,
5923
+ $2::operations_contract_party_party_role_b66e8515fc_enum,
5924
+ $3::operations_contract_party_party_type_1383bb371c_enum,
5925
+ $4, $5, $6, $7, $8, NOW(), NOW()
3329
5926
  )`,
3330
5927
  contractId,
3331
5928
  party.partyRole ?? 'other',
@@ -3365,7 +5962,9 @@ export class OperationsService {
3365
5962
  created_at,
3366
5963
  updated_at
3367
5964
  ) VALUES (
3368
- $1, $2, $3, $4, COALESCE($5, 'pending'), $6, NOW(), NOW()
5965
+ $1, $2, $3, $4,
5966
+ $5::operations_contract_signature_signer_status_1e6fbe2519_enum,
5967
+ $6::timestamp, NOW(), NOW()
3369
5968
  )`,
3370
5969
  contractId,
3371
5970
  signature.signerName,
@@ -3404,7 +6003,14 @@ export class OperationsService {
3404
6003
  created_at,
3405
6004
  updated_at
3406
6005
  ) VALUES (
3407
- $1, COALESCE($2, 'value'), $3, $4, COALESCE($5, 'one_time'), $6, $7, NOW(), NOW()
6006
+ $1,
6007
+ $2::operations_contract_financial_term_term_type_700635c06a_enum,
6008
+ $3,
6009
+ $4,
6010
+ $5::operations_contract_financial_term_recurrence_ba90bbe3bf_enum,
6011
+ $6,
6012
+ $7,
6013
+ NOW(), NOW()
3408
6014
  )`,
3409
6015
  contractId,
3410
6016
  term.termType ?? 'value',
@@ -3443,7 +6049,13 @@ export class OperationsService {
3443
6049
  created_at,
3444
6050
  updated_at
3445
6051
  ) VALUES (
3446
- $1, COALESCE($2, 'revision'), $3, $4, COALESCE($5, 'draft'), $6, NOW(), NOW()
6052
+ $1,
6053
+ $2::operations_contract_revision_revision_type_cf5ba1a538_enum,
6054
+ $3,
6055
+ $4::date,
6056
+ $5::operations_contract_revision_status_f44f35bb66_enum,
6057
+ $6,
6058
+ NOW(), NOW()
3447
6059
  )`,
3448
6060
  contractId,
3449
6061
  revision.revisionType ?? 'revision',
@@ -3455,9 +6067,10 @@ export class OperationsService {
3455
6067
  }
3456
6068
  }
3457
6069
 
3458
- private async replaceContractPdfDocument(
6070
+ private async replaceContractDocument(
3459
6071
  client: any,
3460
6072
  contractId: number,
6073
+ documentType: (typeof CONTRACT_DOCUMENT_TYPE_VALUES)[number],
3461
6074
  document: NonNullable<ContractPayload['replaceUploadedPdfDocument']>
3462
6075
  ) {
3463
6076
  await client.$executeRawUnsafe(
@@ -3466,32 +6079,461 @@ export class OperationsService {
3466
6079
  updated_at = NOW()
3467
6080
  WHERE contract_id = $1
3468
6081
  AND deleted_at IS NULL
3469
- AND document_type IN ('uploaded_pdf', 'generated_pdf')`,
3470
- contractId
6082
+ AND document_type = $2`,
6083
+ contractId,
6084
+ documentType
3471
6085
  );
3472
6086
 
3473
6087
  await client.$executeRawUnsafe(
3474
6088
  `INSERT INTO operations_contract_document (
3475
6089
  contract_id,
3476
6090
  document_type,
6091
+ file_id,
3477
6092
  file_name,
3478
6093
  mime_type,
3479
6094
  file_content_base64,
3480
6095
  is_current,
6096
+ extraction_status,
6097
+ extraction_summary,
3481
6098
  notes,
3482
6099
  created_at,
3483
6100
  updated_at
3484
6101
  ) VALUES (
3485
- $1, 'uploaded_pdf', $2, $3, $4, true, $5, NOW(), NOW()
6102
+ $1, $2, $3, $4, $5, $6, true, $7, $8, $9, NOW(), NOW()
3486
6103
  )`,
3487
6104
  contractId,
6105
+ documentType,
6106
+ document.fileId ?? null,
3488
6107
  document.fileName,
3489
6108
  document.mimeType,
3490
- document.fileContentBase64,
6109
+ document.fileContentBase64 ?? null,
6110
+ document.extractionStatus ?? 'skipped',
6111
+ document.extractionSummary ?? null,
3491
6112
  document.notes ?? null
3492
6113
  );
3493
6114
  }
3494
6115
 
6116
+ private async resolveContractExtractionFile(
6117
+ userId: number,
6118
+ data: ContractExtractDraftPayload
6119
+ ): Promise<MulterFile> {
6120
+ if (data.contractId && data.contractId > 0) {
6121
+ const contract = await this.getContractById(userId, data.contractId);
6122
+ const sourceDocument = contract.documents.find(
6123
+ (document: any) =>
6124
+ document.isCurrent && document.documentType === 'source_upload'
6125
+ );
6126
+
6127
+ if (!sourceDocument) {
6128
+ throw new BadRequestException(
6129
+ 'No source document is attached to this contract draft.'
6130
+ );
6131
+ }
6132
+
6133
+ if (sourceDocument.fileId) {
6134
+ const { buffer } = await this.fileService.getBuffer(sourceDocument.fileId);
6135
+ return this.buildContractExtractionFile(
6136
+ sourceDocument.fileName,
6137
+ sourceDocument.mimeType,
6138
+ buffer
6139
+ );
6140
+ }
6141
+
6142
+ const base64 = this.normalizeExtractionString(
6143
+ sourceDocument.fileContentBase64
6144
+ );
6145
+
6146
+ if (!base64) {
6147
+ throw new BadRequestException(
6148
+ 'The current source document does not contain readable file content.'
6149
+ );
6150
+ }
6151
+
6152
+ return this.buildContractExtractionFile(
6153
+ sourceDocument.fileName,
6154
+ sourceDocument.mimeType,
6155
+ Buffer.from(base64, 'base64')
6156
+ );
6157
+ }
6158
+
6159
+ const fileName = this.normalizeExtractionString(data.fileName);
6160
+ const fileContentBase64 = this.normalizeExtractionString(
6161
+ data.fileContentBase64
6162
+ );
6163
+ const mimeType = this.normalizeContractExtractionMimeType(
6164
+ data.mimeType,
6165
+ fileName
6166
+ );
6167
+
6168
+ if (!fileName || !fileContentBase64) {
6169
+ throw new BadRequestException(
6170
+ 'fileName and fileContentBase64 are required.'
6171
+ );
6172
+ }
6173
+
6174
+ return this.buildContractExtractionFile(
6175
+ fileName,
6176
+ mimeType,
6177
+ Buffer.from(fileContentBase64, 'base64')
6178
+ );
6179
+ }
6180
+
6181
+ private buildContractExtractionFile(
6182
+ fileName: string,
6183
+ mimeType: string,
6184
+ buffer: Buffer
6185
+ ): MulterFile {
6186
+ if (!this.isAllowedContractExtractionMimeType(mimeType)) {
6187
+ throw new BadRequestException(
6188
+ 'Only PDF, DOC, and DOCX files are supported for contract extraction.'
6189
+ );
6190
+ }
6191
+
6192
+ if (!buffer.length) {
6193
+ throw new BadRequestException(
6194
+ 'Unable to decode the uploaded contract document.'
6195
+ );
6196
+ }
6197
+
6198
+ if (buffer.length > 12 * 1024 * 1024) {
6199
+ throw new BadRequestException(
6200
+ 'The uploaded contract document must be smaller than 12 MB.'
6201
+ );
6202
+ }
6203
+
6204
+ return {
6205
+ fieldname: 'file',
6206
+ originalname: fileName,
6207
+ encoding: 'base64',
6208
+ mimetype: mimeType,
6209
+ size: buffer.length,
6210
+ destination: '',
6211
+ filename: fileName,
6212
+ path: '',
6213
+ buffer,
6214
+ };
6215
+ }
6216
+
6217
+ private async renderContractPdfBuffer(contract: any) {
6218
+ const html = await this.buildContractPdfHtml(contract);
6219
+
6220
+ let browser: any = null;
6221
+
6222
+ try {
6223
+ const importPlaywright = new Function(
6224
+ 'moduleName',
6225
+ 'return import(moduleName);'
6226
+ ) as (moduleName: string) => Promise<any>;
6227
+ const playwright = await importPlaywright('playwright');
6228
+ browser = await playwright.chromium.launch({ headless: true });
6229
+ const page = await browser.newPage();
6230
+ await page.setContent(html, { waitUntil: 'networkidle' });
6231
+ return Buffer.from(
6232
+ await page.pdf({
6233
+ format: 'A4',
6234
+ printBackground: true,
6235
+ margin: {
6236
+ top: '72px',
6237
+ right: '18px',
6238
+ bottom: '56px',
6239
+ left: '18px',
6240
+ },
6241
+ })
6242
+ );
6243
+ } catch (error) {
6244
+ const errorMessage =
6245
+ error instanceof Error ? error.message : String(error);
6246
+ const errorStack =
6247
+ error instanceof Error ? (error.stack ?? error.message) : String(error);
6248
+ const missingPlaywrightRuntime =
6249
+ /Cannot find package ['"]playwright['"]|Cannot find module ['"]playwright['"]|Executable doesn't exist|browserType\.launch|Failed to launch|Please run the following command to download new browsers/i.test(
6250
+ errorMessage
6251
+ );
6252
+
6253
+ this.logger.error(
6254
+ `Failed to generate contract PDF for contract ${contract?.id ?? 'unknown'}. ${errorMessage}`,
6255
+ errorStack
6256
+ );
6257
+
6258
+ throw new InternalServerErrorException(
6259
+ missingPlaywrightRuntime
6260
+ ? 'PDF generation is unavailable because Playwright/Chromium is not installed on the server. Run `pnpm --filter api run playwright:install` in the API environment.'
6261
+ : 'Failed to generate the PDF document. Check server logs for details.'
6262
+ );
6263
+ } finally {
6264
+ await browser?.close?.();
6265
+ }
6266
+ }
6267
+
6268
+ private async buildContractPdfHtml(contract: any) {
6269
+ const logoUrl = await this.resolveContractPdfLogoUrl();
6270
+ const contentHtml =
6271
+ this.normalizeOptionalText(contract.contentHtml) ??
6272
+ this.normalizeOptionalText(contract.description)
6273
+ ?.split(/\n{2,}/)
6274
+ .map((paragraph) => `<p>${this.escapeHtml(paragraph)}</p>`)
6275
+ .join('') ??
6276
+ '<p>No contract body was provided yet.</p>';
6277
+ const partiesHtml = (contract.parties ?? []).length
6278
+ ? contract.parties
6279
+ .map(
6280
+ (party: any) => `
6281
+ <li>
6282
+ <strong>${this.escapeHtml(party.displayName || 'Party')}</strong>
6283
+ <span>${this.escapeHtml(
6284
+ [party.partyRole, party.partyType].filter(Boolean).join(' • ')
6285
+ )}</span>
6286
+ </li>`
6287
+ )
6288
+ .join('')
6289
+ : '<li><strong>No parties registered yet.</strong><span>Complete this contract later if needed.</span></li>';
6290
+ const financialTermsHtml = (contract.financialTerms ?? []).length
6291
+ ? contract.financialTerms
6292
+ .map(
6293
+ (term: any) => `
6294
+ <li>
6295
+ <strong>${this.escapeHtml(term.label || 'Term')}</strong>
6296
+ <span>${this.escapeHtml(
6297
+ [term.termType, term.amount, term.recurrence]
6298
+ .filter((item) => item !== null && item !== undefined && String(item).trim())
6299
+ .join(' • ')
6300
+ )}</span>
6301
+ </li>`
6302
+ )
6303
+ .join('')
6304
+ : '<li><strong>No financial terms registered yet.</strong><span>The draft is intentionally lightweight.</span></li>';
6305
+
6306
+ return `<!DOCTYPE html>
6307
+ <html lang="en">
6308
+ <head>
6309
+ <meta charset="utf-8" />
6310
+ <style>
6311
+ @page {
6312
+ size: A4;
6313
+ margin: 72px 18px 56px 18px;
6314
+ }
6315
+ body {
6316
+ color: #0f172a;
6317
+ font-family: Arial, sans-serif;
6318
+ font-size: 12px;
6319
+ line-height: 1.5;
6320
+ margin: 0;
6321
+ }
6322
+ header {
6323
+ align-items: center;
6324
+ border-bottom: 2px solid #dbeafe;
6325
+ display: flex;
6326
+ gap: 16px;
6327
+ justify-content: space-between;
6328
+ margin-bottom: 24px;
6329
+ padding-bottom: 16px;
6330
+ }
6331
+ .brand {
6332
+ align-items: center;
6333
+ display: flex;
6334
+ gap: 12px;
6335
+ }
6336
+ .brand img {
6337
+ height: 42px;
6338
+ max-width: 140px;
6339
+ object-fit: contain;
6340
+ }
6341
+ .eyebrow {
6342
+ color: #2563eb;
6343
+ font-size: 10px;
6344
+ font-weight: 700;
6345
+ letter-spacing: 0.12em;
6346
+ text-transform: uppercase;
6347
+ }
6348
+ h1 {
6349
+ font-size: 22px;
6350
+ margin: 4px 0 0;
6351
+ }
6352
+ .meta-grid {
6353
+ display: grid;
6354
+ gap: 12px;
6355
+ grid-template-columns: repeat(2, minmax(0, 1fr));
6356
+ margin: 20px 0;
6357
+ }
6358
+ .meta-card {
6359
+ background: #f8fafc;
6360
+ border: 1px solid #dbeafe;
6361
+ border-radius: 12px;
6362
+ padding: 12px;
6363
+ }
6364
+ .meta-card strong {
6365
+ display: block;
6366
+ font-size: 10px;
6367
+ letter-spacing: 0.08em;
6368
+ margin-bottom: 4px;
6369
+ text-transform: uppercase;
6370
+ }
6371
+ section {
6372
+ margin-top: 20px;
6373
+ }
6374
+ h2 {
6375
+ color: #1d4ed8;
6376
+ font-size: 14px;
6377
+ margin: 0 0 10px;
6378
+ }
6379
+ ul {
6380
+ list-style: none;
6381
+ margin: 0;
6382
+ padding: 0;
6383
+ }
6384
+ li {
6385
+ border: 1px solid #e2e8f0;
6386
+ border-radius: 10px;
6387
+ margin-bottom: 8px;
6388
+ padding: 10px 12px;
6389
+ }
6390
+ li strong {
6391
+ display: block;
6392
+ margin-bottom: 2px;
6393
+ }
6394
+ footer {
6395
+ border-top: 1px solid #dbeafe;
6396
+ color: #475569;
6397
+ font-size: 10px;
6398
+ margin-top: 28px;
6399
+ padding-top: 12px;
6400
+ }
6401
+ .content {
6402
+ border: 1px solid #e2e8f0;
6403
+ border-radius: 14px;
6404
+ padding: 18px;
6405
+ }
6406
+ .content p:first-child {
6407
+ margin-top: 0;
6408
+ }
6409
+ </style>
6410
+ </head>
6411
+ <body>
6412
+ <header>
6413
+ <div class="brand">
6414
+ <img src="${logoUrl}" alt="System logo" />
6415
+ <div>
6416
+ <div class="eyebrow">Contract Document</div>
6417
+ <h1>${this.escapeHtml(
6418
+ contract.name || contract.code || 'Contract draft'
6419
+ )}</h1>
6420
+ </div>
6421
+ </div>
6422
+ <div>
6423
+ <div><strong>Code:</strong> ${this.escapeHtml(
6424
+ contract.code || 'Draft'
6425
+ )}</div>
6426
+ <div><strong>Status:</strong> ${this.escapeHtml(
6427
+ this.humanizeEnumLabel(contract.status || 'draft')
6428
+ )}</div>
6429
+ </div>
6430
+ </header>
6431
+
6432
+ <div class="meta-grid">
6433
+ <div class="meta-card">
6434
+ <strong>Client</strong>
6435
+ ${this.escapeHtml(contract.clientName || 'Not informed yet')}
6436
+ </div>
6437
+ <div class="meta-card">
6438
+ <strong>Type</strong>
6439
+ ${this.escapeHtml(
6440
+ this.humanizeEnumLabel(contract.contractType || 'service_agreement')
6441
+ )}
6442
+ </div>
6443
+ <div class="meta-card">
6444
+ <strong>Period</strong>
6445
+ ${this.escapeHtml(
6446
+ [contract.startDate || 'Open start', contract.endDate || 'Open end'].join(
6447
+ ' - '
6448
+ )
6449
+ )}
6450
+ </div>
6451
+ <div class="meta-card">
6452
+ <strong>Billing</strong>
6453
+ ${this.escapeHtml(
6454
+ this.humanizeEnumLabel(contract.billingModel || 'time_and_material')
6455
+ )}
6456
+ </div>
6457
+ </div>
6458
+
6459
+ <section>
6460
+ <h2>Related Parties</h2>
6461
+ <ul>${partiesHtml}</ul>
6462
+ </section>
6463
+
6464
+ <section>
6465
+ <h2>Financial Terms</h2>
6466
+ <ul>${financialTermsHtml}</ul>
6467
+ </section>
6468
+
6469
+ <section>
6470
+ <h2>Contract Body</h2>
6471
+ <div class="content">${contentHtml}</div>
6472
+ </section>
6473
+
6474
+ <footer>
6475
+ Generated on ${this.escapeHtml(new Date().toISOString())} for ${
6476
+ this.escapeHtml(contract.code || 'draft')
6477
+ }.
6478
+ </footer>
6479
+ </body>
6480
+ </html>`;
6481
+ }
6482
+
6483
+ private async resolveContractPdfLogoUrl() {
6484
+ const settings = await this.settingService.getSettingValues([
6485
+ 'image-url',
6486
+ 'icon-url',
6487
+ ]);
6488
+ const configuredUrl =
6489
+ this.normalizeOptionalText(settings['image-url']) ??
6490
+ this.normalizeOptionalText(settings['icon-url']);
6491
+
6492
+ if (configuredUrl && /^https?:\/\//i.test(configuredUrl)) {
6493
+ return configuredUrl;
6494
+ }
6495
+
6496
+ const inlineSvg = encodeURIComponent(
6497
+ `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72">
6498
+ <rect width="240" height="72" rx="18" fill="#0f172a"/>
6499
+ <circle cx="38" cy="36" r="18" fill="#3b82f6"/>
6500
+ <text x="72" y="43" fill="#ffffff" font-size="26" font-family="Arial, sans-serif" font-weight="700">HedHog</text>
6501
+ </svg>`
6502
+ );
6503
+
6504
+ return `data:image/svg+xml;charset=UTF-8,${inlineSvg}`;
6505
+ }
6506
+
6507
+ private buildContractPdfFileName(contract: any) {
6508
+ const base =
6509
+ this.normalizeOptionalText(contract.code) ??
6510
+ this.normalizeOptionalText(contract.name) ??
6511
+ `contract-${contract.id}`;
6512
+
6513
+ return `${base
6514
+ .toLowerCase()
6515
+ .replace(/[^a-z0-9]+/g, '-')
6516
+ .replace(/^-+|-+$/g, '')
6517
+ .slice(0, 80) || `contract-${contract.id}`}.pdf`;
6518
+ }
6519
+
6520
+ private humanizeEnumLabel(value: string) {
6521
+ return String(value ?? '')
6522
+ .split('_')
6523
+ .filter(Boolean)
6524
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
6525
+ .join(' ');
6526
+ }
6527
+
6528
+ private escapeHtml(value: string) {
6529
+ return String(value ?? '')
6530
+ .replace(/&/g, '&amp;')
6531
+ .replace(/</g, '&lt;')
6532
+ .replace(/>/g, '&gt;')
6533
+ .replace(/"/g, '&quot;')
6534
+ .replace(/'/g, '&#39;');
6535
+ }
6536
+
3495
6537
  private async insertContractHistory(
3496
6538
  client: any,
3497
6539
  contractId: number,
@@ -3519,6 +6561,745 @@ export class OperationsService {
3519
6561
  );
3520
6562
  }
3521
6563
 
6564
+ private async generateCollaboratorCode(client: any) {
6565
+ for (let attempt = 0; attempt < 5; attempt += 1) {
6566
+ const candidate = `COL-${Date.now().toString(36).toUpperCase()}${Math.random()
6567
+ .toString(36)
6568
+ .slice(2, 6)
6569
+ .toUpperCase()}`;
6570
+ const existing = (await client.$queryRawUnsafe(
6571
+ `SELECT code
6572
+ FROM operations_collaborator
6573
+ WHERE code = $1
6574
+ LIMIT 1`,
6575
+ candidate
6576
+ )) as { code: string }[];
6577
+
6578
+ if (!existing.length) {
6579
+ return candidate;
6580
+ }
6581
+ }
6582
+
6583
+ throw new BadRequestException('Unable to generate collaborator code.');
6584
+ }
6585
+
6586
+ private async generateContractCode(client: any) {
6587
+ for (let attempt = 0; attempt < 5; attempt += 1) {
6588
+ const candidate = `CTR-${Date.now().toString(36).toUpperCase()}${Math.random()
6589
+ .toString(36)
6590
+ .slice(2, 6)
6591
+ .toUpperCase()}`.slice(0, 40);
6592
+ const existing = (await client.$queryRawUnsafe(
6593
+ `SELECT code
6594
+ FROM operations_contract
6595
+ WHERE code = $1
6596
+ LIMIT 1`,
6597
+ candidate
6598
+ )) as { code: string }[];
6599
+
6600
+ if (!existing.length) {
6601
+ return candidate;
6602
+ }
6603
+ }
6604
+
6605
+ throw new BadRequestException('Unable to generate contract code.');
6606
+ }
6607
+
6608
+ private async generateContractContentHtml(
6609
+ contract: any,
6610
+ data: ContractGenerateContentPayload = {}
6611
+ ) {
6612
+ const currentContent = this.normalizeOptionalText(contract.contentHtml);
6613
+
6614
+ const primaryParty =
6615
+ (contract.parties ?? []).find((party: any) => party.isPrimary) ??
6616
+ (contract.parties ?? [])[0] ??
6617
+ null;
6618
+ const templateContext = {
6619
+ contract_code: this.normalizeOptionalText(contract.code) ?? '',
6620
+ contract_name: this.normalizeOptionalText(contract.name) ?? '',
6621
+ client_name:
6622
+ this.normalizeOptionalText(contract.clientName) ??
6623
+ this.normalizeOptionalText(primaryParty?.displayName) ??
6624
+ '',
6625
+ contract_category: this.normalizeOptionalText(contract.contractCategory) ?? '',
6626
+ contract_type: this.normalizeOptionalText(contract.contractType) ?? '',
6627
+ start_date: this.normalizeOptionalText(contract.startDate) ?? '',
6628
+ end_date: this.normalizeOptionalText(contract.endDate) ?? '',
6629
+ effective_date: this.normalizeOptionalText(contract.effectiveDate) ?? '',
6630
+ signed_at: this.normalizeOptionalText(contract.signedAt) ?? '',
6631
+ budget_amount:
6632
+ contract.budgetAmount !== null && contract.budgetAmount !== undefined
6633
+ ? String(contract.budgetAmount)
6634
+ : '',
6635
+ monthly_hour_cap:
6636
+ contract.monthlyHourCap !== null &&
6637
+ contract.monthlyHourCap !== undefined
6638
+ ? String(contract.monthlyHourCap)
6639
+ : '',
6640
+ primary_party_name:
6641
+ this.normalizeOptionalText(primaryParty?.displayName) ?? '',
6642
+ primary_party_document:
6643
+ this.normalizeOptionalText(primaryParty?.documentNumber) ?? '',
6644
+ primary_party_email: this.normalizeOptionalText(primaryParty?.email) ?? '',
6645
+ primary_party_phone: this.normalizeOptionalText(primaryParty?.phone) ?? '',
6646
+ description: this.normalizeOptionalText(contract.description) ?? '',
6647
+ };
6648
+
6649
+ const applyTemplateVariables = (value: string | null | undefined) => {
6650
+ const source = value ?? '';
6651
+ return Object.entries(templateContext).reduce(
6652
+ (result, [key, replacement]) =>
6653
+ result.split(`{{${key}}}`).join(replacement || ''),
6654
+ source
6655
+ );
6656
+ };
6657
+
6658
+ const currentHtmlWithVariables = this.normalizeOptionalText(
6659
+ applyTemplateVariables(currentContent)
6660
+ );
6661
+
6662
+ if (currentHtmlWithVariables && data.overwrite !== true) {
6663
+ return currentHtmlWithVariables;
6664
+ }
6665
+
6666
+ const baseHtml =
6667
+ currentHtmlWithVariables ?? this.buildFallbackContractContent(contract);
6668
+ const promptMessage =
6669
+ this.normalizeOptionalText(data.promptMessage) ??
6670
+ 'Generate an initial contract body using the registered metadata and keep the language concise, professional, and ready for legal review.';
6671
+
6672
+ try {
6673
+ const aiResult = await this.aiService.chat({
6674
+ provider: data.provider === 'gemini' ? 'gemini' : 'openai',
6675
+ model: data.provider === 'gemini' ? 'gemini-1.5-flash' : 'gpt-4o-mini',
6676
+ message: promptMessage,
6677
+ systemPrompt: [
6678
+ 'You draft professional business contract content for an operations back-office system.',
6679
+ 'Return ONLY clean HTML suitable for a rich text editor preview.',
6680
+ 'Do not include Markdown fences, commentary, or legal disclaimers inside the HTML.',
6681
+ 'Keep the output concise, well-structured, and ready for legal revision.',
6682
+ 'If a specific detail is missing, preserve placeholders like {{client_name}}, {{start_date}}, {{end_date}}, {{budget_amount}}, and {{monthly_hour_cap}} instead of inventing facts.',
6683
+ `Contract context:\n${JSON.stringify(templateContext, null, 2)}`,
6684
+ baseHtml
6685
+ ? `Current base HTML:\n${baseHtml}`
6686
+ : 'There is no current HTML yet.',
6687
+ ].join('\n\n'),
6688
+ });
6689
+
6690
+ return (
6691
+ this.normalizeOptionalText(String(aiResult?.content ?? '')) ?? baseHtml
6692
+ );
6693
+ } catch {
6694
+ return baseHtml;
6695
+ }
6696
+ }
6697
+
6698
+ private buildFallbackContractContent(contract: any) {
6699
+ const title = this.escapeHtml(
6700
+ this.normalizeOptionalText(contract.name) ??
6701
+ this.normalizeOptionalText(contract.code) ??
6702
+ 'Contract Draft'
6703
+ );
6704
+ const description = this.escapeHtml(
6705
+ this.normalizeOptionalText(contract.description) ??
6706
+ 'This draft summarizes the main commercial and operational conditions agreed between the parties.'
6707
+ );
6708
+ const validityItems = [
6709
+ contract.startDate ? `Start date: ${contract.startDate}` : null,
6710
+ contract.endDate ? `End date: ${contract.endDate}` : null,
6711
+ contract.effectiveDate ? `Effective date: ${contract.effectiveDate}` : null,
6712
+ contract.signedAt ? `Signed at: ${contract.signedAt}` : null,
6713
+ ]
6714
+ .filter(Boolean)
6715
+ .map((item) => `<li>${this.escapeHtml(String(item))}</li>`)
6716
+ .join('');
6717
+ const partiesHtml = (contract.parties ?? []).length
6718
+ ? (contract.parties ?? [])
6719
+ .map(
6720
+ (party: any) => `
6721
+ <li>
6722
+ <strong>${this.escapeHtml(party.displayName || 'Party')}</strong>
6723
+ <span>${this.escapeHtml(
6724
+ [party.partyRole, party.partyType, party.documentNumber]
6725
+ .filter(Boolean)
6726
+ .join(' • ')
6727
+ )}</span>
6728
+ </li>`
6729
+ )
6730
+ .join('')
6731
+ : '<li><strong>Main party</strong><span>{{client_name}}</span></li>';
6732
+ const financialTermsHtml = (contract.financialTerms ?? []).length
6733
+ ? (contract.financialTerms ?? [])
6734
+ .map(
6735
+ (term: any) => `
6736
+ <li>
6737
+ <strong>${this.escapeHtml(term.label || 'Financial term')}</strong>
6738
+ <span>${this.escapeHtml(
6739
+ [term.termType, term.amount, term.recurrence]
6740
+ .filter(
6741
+ (item) =>
6742
+ item !== null &&
6743
+ item !== undefined &&
6744
+ String(item).trim()
6745
+ )
6746
+ .join(' • ')
6747
+ )}</span>
6748
+ </li>`
6749
+ )
6750
+ .join('')
6751
+ : '<li><strong>Commercial conditions</strong><span>Define prices, billing cadence, and penalties before signature.</span></li>';
6752
+
6753
+ return `
6754
+ <h1>${title}</h1>
6755
+ <p>${description}</p>
6756
+ <h2>1. Parties</h2>
6757
+ <ul>${partiesHtml}</ul>
6758
+ <h2>2. Scope and object</h2>
6759
+ <p>The parties agree to execute the services and obligations described in this contract, according to the agreed operational, technical, and commercial conditions.</p>
6760
+ <h2>3. Validity</h2>
6761
+ ${validityItems ? `<ul>${validityItems}</ul>` : '<p>Define start date, term, renewal, and termination conditions.</p>'}
6762
+ <h2>4. Financial terms</h2>
6763
+ <ul>${financialTermsHtml}</ul>
6764
+ <h2>5. General obligations</h2>
6765
+ <p>Review confidentiality, data protection, service levels, termination, and liability clauses before formal approval.</p>
6766
+ `;
6767
+ }
6768
+
6769
+ private async buildContractLegalReview(
6770
+ contract: any,
6771
+ data: ContractLegalReviewPayload = {}
6772
+ ): Promise<ContractLegalReviewResult> {
6773
+ const heuristicReview = this.buildHeuristicContractLegalReview(contract);
6774
+ const promptMessage =
6775
+ this.normalizeOptionalText(data.promptMessage) ??
6776
+ 'Review this contract draft and return an advisory legal checklist with practical warnings and missing items.';
6777
+
6778
+ try {
6779
+ const aiResult = await this.aiService.chat({
6780
+ provider: data.provider === 'gemini' ? 'gemini' : 'openai',
6781
+ model: data.provider === 'gemini' ? 'gemini-1.5-flash' : 'gpt-4o-mini',
6782
+ message: promptMessage,
6783
+ systemPrompt: [
6784
+ 'You are an advisory legal-review assistant for business contracts.',
6785
+ 'Return ONLY valid JSON. Do not include Markdown fences or commentary.',
6786
+ 'This review is non-blocking and should help a human reviewer spot likely gaps.',
6787
+ 'Expected JSON shape:',
6788
+ JSON.stringify(
6789
+ {
6790
+ summary: '',
6791
+ missingFields: [],
6792
+ warnings: [],
6793
+ checklist: [],
6794
+ status: 'attention_required',
6795
+ },
6796
+ null,
6797
+ 2
6798
+ ),
6799
+ `Contract metadata:\n${JSON.stringify(
6800
+ {
6801
+ code: contract.code,
6802
+ name: contract.name,
6803
+ clientName: contract.clientName,
6804
+ contractCategory: contract.contractCategory,
6805
+ contractType: contract.contractType,
6806
+ startDate: contract.startDate,
6807
+ endDate: contract.endDate,
6808
+ effectiveDate: contract.effectiveDate,
6809
+ signatureStatus: contract.signatureStatus,
6810
+ parties: contract.parties,
6811
+ financialTerms: contract.financialTerms,
6812
+ },
6813
+ null,
6814
+ 2
6815
+ )}`,
6816
+ `Contract HTML:\n${this.normalizeOptionalText(contract.contentHtml) ?? ''}`,
6817
+ ].join('\n\n'),
6818
+ });
6819
+
6820
+ const parsed = this.parseAiJsonPayload(String(aiResult?.content ?? ''));
6821
+ const missingFields = Array.from(
6822
+ new Set([
6823
+ ...heuristicReview.missingFields,
6824
+ ...this.normalizeStringList(parsed.missingFields),
6825
+ ])
6826
+ );
6827
+ const warnings = Array.from(
6828
+ new Set([
6829
+ ...heuristicReview.warnings,
6830
+ ...this.normalizeStringList(parsed.warnings),
6831
+ ])
6832
+ );
6833
+ const checklist = Array.from(
6834
+ new Set([
6835
+ ...this.normalizeStringList(parsed.checklist),
6836
+ ...heuristicReview.checklist,
6837
+ ])
6838
+ );
6839
+ const parsedStatus = this.normalizeExtractionString(parsed.status);
6840
+ const status: ContractLegalReviewResult['status'] =
6841
+ parsedStatus === 'ready_for_revision' ||
6842
+ parsedStatus === 'attention_required'
6843
+ ? parsedStatus
6844
+ : missingFields.length || warnings.length
6845
+ ? 'attention_required'
6846
+ : 'ready_for_revision';
6847
+
6848
+ return {
6849
+ summary:
6850
+ this.normalizeExtractionString(parsed.summary) ||
6851
+ heuristicReview.summary,
6852
+ missingFields,
6853
+ warnings,
6854
+ checklist,
6855
+ status,
6856
+ reviewedAt: new Date().toISOString(),
6857
+ };
6858
+ } catch {
6859
+ return heuristicReview;
6860
+ }
6861
+ }
6862
+
6863
+ private buildHeuristicContractLegalReview(
6864
+ contract: any
6865
+ ): ContractLegalReviewResult {
6866
+ const contentText = (
6867
+ this.normalizeOptionalText(contract.contentHtml) ?? ''
6868
+ )
6869
+ .replace(/<[^>]+>/g, ' ')
6870
+ .replace(/\s+/g, ' ')
6871
+ .trim()
6872
+ .toLowerCase();
6873
+ const primaryParty =
6874
+ (contract.parties ?? []).find((party: any) => party.isPrimary) ??
6875
+ (contract.parties ?? [])[0] ??
6876
+ null;
6877
+ const missingFields: string[] = [];
6878
+ const warnings: string[] = [];
6879
+ const checklist: string[] = [];
6880
+
6881
+ if (!this.normalizeOptionalText(contract.name)) {
6882
+ missingFields.push('Contract title');
6883
+ checklist.push('Attention: define a clear contract title.');
6884
+ } else {
6885
+ checklist.push('OK: contract title is registered.');
6886
+ }
6887
+
6888
+ if (!this.normalizeOptionalText(contract.clientName)) {
6889
+ missingFields.push('Client or primary party name');
6890
+ checklist.push('Attention: confirm the client or main party identification.');
6891
+ } else {
6892
+ checklist.push('OK: the main party is identified.');
6893
+ }
6894
+
6895
+ if (!contract.startDate && !contract.effectiveDate) {
6896
+ missingFields.push('Start or effective date');
6897
+ checklist.push('Attention: define the term start or effective date.');
6898
+ } else {
6899
+ checklist.push('OK: validity dates were provided.');
6900
+ }
6901
+
6902
+ if (!(contract.parties ?? []).length) {
6903
+ missingFields.push('Related parties');
6904
+ warnings.push('No related parties are registered in the contract draft.');
6905
+ }
6906
+
6907
+ if (!this.normalizeOptionalText(primaryParty?.documentNumber)) {
6908
+ warnings.push('Consider confirming the primary party document number.');
6909
+ }
6910
+
6911
+ if (!(contract.financialTerms ?? []).length && contract.budgetAmount == null) {
6912
+ warnings.push('Commercial conditions are still generic; define prices, recurrence, and penalties.');
6913
+ checklist.push('Attention: validate billing, adjustments, and penalties.');
6914
+ } else {
6915
+ checklist.push('OK: there is at least one financial reference to review.');
6916
+ }
6917
+
6918
+ if (!contentText) {
6919
+ missingFields.push('Contract body content');
6920
+ warnings.push('The contract body is still empty and needs a first draft.');
6921
+ } else {
6922
+ if (contentText.length < 600) {
6923
+ warnings.push('The contract body is still brief; validate scope, obligations, and exceptions in detail.');
6924
+ }
6925
+ if (!/(rescis|terminat|cancel)/.test(contentText)) {
6926
+ warnings.push('Add a termination / rescission clause with notice and penalty rules.');
6927
+ }
6928
+ if (!/(confiden|sigil|data protect|privacy)/.test(contentText)) {
6929
+ warnings.push('Review confidentiality and data protection clauses when applicable.');
6930
+ }
6931
+ if (!/(foro|jurisd|governing law|applicable law)/.test(contentText)) {
6932
+ warnings.push('Review the governing law / forum clause before signature.');
6933
+ }
6934
+ }
6935
+
6936
+ const summary =
6937
+ missingFields.length || warnings.length
6938
+ ? `Advisory legal review found ${missingFields.length} missing item(s) and ${warnings.length} point(s) for attention before final approval.`
6939
+ : 'Advisory legal review found the draft structurally consistent for human legal revision.';
6940
+
6941
+ return {
6942
+ summary,
6943
+ missingFields: Array.from(new Set(missingFields)),
6944
+ warnings: Array.from(new Set(warnings)),
6945
+ checklist: Array.from(new Set(checklist)),
6946
+ status:
6947
+ missingFields.length || warnings.length
6948
+ ? 'attention_required'
6949
+ : 'ready_for_revision',
6950
+ reviewedAt: new Date().toISOString(),
6951
+ };
6952
+ }
6953
+
6954
+ private buildContractExtractionSystemPrompt() {
6955
+ return [
6956
+ 'You extract structured draft data from uploaded business contracts.',
6957
+ 'Return ONLY valid JSON. Do not include Markdown fences, prose, or comments.',
6958
+ 'Use empty strings or empty arrays when the document is unclear; never invent legal facts.',
6959
+ 'Dates must use YYYY-MM-DD whenever the document provides enough confidence.',
6960
+ 'For contentHtml, return clean HTML suitable for a rich text editor preview.',
6961
+ 'Expected JSON shape:',
6962
+ JSON.stringify(
6963
+ {
6964
+ code: '',
6965
+ name: '',
6966
+ clientName: '',
6967
+ contractCategory: 'client',
6968
+ contractType: 'service_agreement',
6969
+ signatureStatus: 'not_started',
6970
+ billingModel: 'time_and_material',
6971
+ originType: 'manual',
6972
+ originId: '',
6973
+ startDate: '',
6974
+ endDate: '',
6975
+ signedAt: '',
6976
+ effectiveDate: '',
6977
+ budgetAmount: '',
6978
+ monthlyHourCap: '',
6979
+ status: 'draft',
6980
+ description: '',
6981
+ contentHtml: '',
6982
+ parties: [
6983
+ {
6984
+ displayName: '',
6985
+ partyRole: 'client',
6986
+ partyType: 'company',
6987
+ documentNumber: '',
6988
+ email: '',
6989
+ phone: '',
6990
+ isPrimary: true,
6991
+ },
6992
+ ],
6993
+ signatures: [
6994
+ {
6995
+ signerName: '',
6996
+ signerRole: '',
6997
+ signerEmail: '',
6998
+ status: 'pending',
6999
+ signedAt: '',
7000
+ },
7001
+ ],
7002
+ financialTerms: [
7003
+ {
7004
+ label: '',
7005
+ termType: 'value',
7006
+ amount: '',
7007
+ recurrence: 'one_time',
7008
+ dueDay: '',
7009
+ notes: '',
7010
+ },
7011
+ ],
7012
+ revisions: [
7013
+ {
7014
+ title: '',
7015
+ revisionType: 'revision',
7016
+ effectiveDate: '',
7017
+ status: 'draft',
7018
+ summary: '',
7019
+ },
7020
+ ],
7021
+ summary: '',
7022
+ missingFields: [],
7023
+ warnings: [],
7024
+ },
7025
+ null,
7026
+ 2
7027
+ ),
7028
+ ].join('\n\n');
7029
+ }
7030
+
7031
+ private parseAiJsonPayload(content: string): Record<string, unknown> {
7032
+ const trimmed = String(content ?? '').trim();
7033
+
7034
+ if (!trimmed) {
7035
+ throw new BadRequestException('AI returned an empty contract draft response.');
7036
+ }
7037
+
7038
+ const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
7039
+ const candidate = fencedMatch?.[1]?.trim() || trimmed;
7040
+
7041
+ try {
7042
+ return JSON.parse(candidate) as Record<string, unknown>;
7043
+ } catch {
7044
+ const firstBraceIndex = candidate.indexOf('{');
7045
+ const lastBraceIndex = candidate.lastIndexOf('}');
7046
+
7047
+ if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
7048
+ try {
7049
+ return JSON.parse(
7050
+ candidate.slice(firstBraceIndex, lastBraceIndex + 1)
7051
+ ) as Record<string, unknown>;
7052
+ } catch {
7053
+ throw new BadRequestException(
7054
+ 'AI returned an invalid contract draft payload.'
7055
+ );
7056
+ }
7057
+ }
7058
+
7059
+ throw new BadRequestException('AI returned an invalid contract draft payload.');
7060
+ }
7061
+ }
7062
+
7063
+ private normalizeContractExtractDraft(raw: Record<string, unknown>) {
7064
+ const warnings = this.normalizeStringList(raw.warnings);
7065
+ const missingFields = this.normalizeStringList(raw.missingFields);
7066
+
7067
+ const draft = {
7068
+ code: this.normalizeExtractionString(raw.code),
7069
+ name: this.normalizeExtractionString(raw.name),
7070
+ clientName: this.normalizeExtractionString(raw.clientName),
7071
+ contractCategory: this.normalizeEnumValue(
7072
+ raw.contractCategory,
7073
+ CONTRACT_CATEGORY_VALUES,
7074
+ 'client'
7075
+ ),
7076
+ contractType: this.normalizeEnumValue(
7077
+ raw.contractType,
7078
+ CONTRACT_TYPE_VALUES,
7079
+ 'service_agreement'
7080
+ ),
7081
+ signatureStatus: this.normalizeEnumValue(
7082
+ raw.signatureStatus,
7083
+ SIGNATURE_STATUS_VALUES,
7084
+ 'not_started'
7085
+ ),
7086
+ isActive: raw.isActive === false ? false : true,
7087
+ billingModel: this.normalizeEnumValue(
7088
+ raw.billingModel,
7089
+ BILLING_MODEL_VALUES,
7090
+ 'time_and_material'
7091
+ ),
7092
+ originType: this.normalizeEnumValue(
7093
+ raw.originType,
7094
+ ORIGIN_TYPE_VALUES,
7095
+ 'manual'
7096
+ ),
7097
+ originId: this.normalizeExtractionString(raw.originId),
7098
+ startDate: this.normalizeExtractionDate(raw.startDate),
7099
+ endDate: this.normalizeExtractionDate(raw.endDate),
7100
+ signedAt: this.normalizeExtractionDate(raw.signedAt),
7101
+ effectiveDate: this.normalizeExtractionDate(raw.effectiveDate),
7102
+ budgetAmount: this.normalizeExtractionNumber(raw.budgetAmount),
7103
+ monthlyHourCap: this.normalizeExtractionNumber(raw.monthlyHourCap),
7104
+ status: this.normalizeEnumValue(raw.status, CONTRACT_STATUS_VALUES, 'draft'),
7105
+ description: this.normalizeExtractionString(raw.description),
7106
+ contentHtml: this.normalizeExtractionString(raw.contentHtml),
7107
+ summary: this.normalizeExtractionString(raw.summary),
7108
+ parties: this.normalizeObjectList(raw.parties)
7109
+ .map((party) => ({
7110
+ displayName: this.normalizeExtractionString(party.displayName),
7111
+ partyRole: this.normalizeEnumValue(
7112
+ party.partyRole,
7113
+ PARTY_ROLE_VALUES,
7114
+ 'client'
7115
+ ),
7116
+ partyType: this.normalizeEnumValue(
7117
+ party.partyType,
7118
+ PARTY_TYPE_VALUES,
7119
+ 'company'
7120
+ ),
7121
+ documentNumber: this.normalizeExtractionString(party.documentNumber),
7122
+ email: this.normalizeExtractionString(party.email),
7123
+ phone: this.normalizeExtractionString(party.phone),
7124
+ isPrimary: this.normalizeExtractionBoolean(party.isPrimary),
7125
+ }))
7126
+ .filter((party) => party.displayName),
7127
+ signatures: this.normalizeObjectList(raw.signatures)
7128
+ .map((signature) => ({
7129
+ signerName: this.normalizeExtractionString(signature.signerName),
7130
+ signerRole: this.normalizeExtractionString(signature.signerRole),
7131
+ signerEmail: this.normalizeExtractionString(signature.signerEmail),
7132
+ status: this.normalizeEnumValue(
7133
+ signature.status,
7134
+ SIGNATURE_ITEM_STATUS_VALUES,
7135
+ 'pending'
7136
+ ),
7137
+ signedAt: this.normalizeExtractionDate(signature.signedAt),
7138
+ }))
7139
+ .filter((signature) => signature.signerName),
7140
+ financialTerms: this.normalizeObjectList(raw.financialTerms)
7141
+ .map((term) => ({
7142
+ label: this.normalizeExtractionString(term.label),
7143
+ termType: this.normalizeEnumValue(
7144
+ term.termType,
7145
+ FINANCIAL_TERM_TYPE_VALUES,
7146
+ 'value'
7147
+ ),
7148
+ amount: this.normalizeExtractionNumber(term.amount),
7149
+ recurrence: this.normalizeEnumValue(
7150
+ term.recurrence,
7151
+ RECURRENCE_VALUES,
7152
+ 'one_time'
7153
+ ),
7154
+ dueDay: this.normalizeExtractionNumber(term.dueDay),
7155
+ notes: this.normalizeExtractionString(term.notes),
7156
+ }))
7157
+ .filter((term) => term.label),
7158
+ revisions: this.normalizeObjectList(raw.revisions)
7159
+ .map((revision) => ({
7160
+ title: this.normalizeExtractionString(revision.title),
7161
+ revisionType: this.normalizeEnumValue(
7162
+ revision.revisionType,
7163
+ REVISION_TYPE_VALUES,
7164
+ 'revision'
7165
+ ),
7166
+ effectiveDate: this.normalizeExtractionDate(revision.effectiveDate),
7167
+ status: this.normalizeEnumValue(
7168
+ revision.status,
7169
+ REVISION_STATUS_VALUES,
7170
+ 'draft'
7171
+ ),
7172
+ summary: this.normalizeExtractionString(revision.summary),
7173
+ }))
7174
+ .filter((revision) => revision.title),
7175
+ };
7176
+
7177
+ if (!draft.name) missingFields.push('Contract title');
7178
+ if (!draft.clientName) missingFields.push('Client name');
7179
+ if (!draft.startDate) missingFields.push('Start date');
7180
+ if (!draft.contentHtml && !draft.summary && !draft.description) {
7181
+ warnings.push(
7182
+ 'The contract body was not extracted with enough confidence. Use the editor to complete it.'
7183
+ );
7184
+ }
7185
+ if (!draft.parties.length) {
7186
+ warnings.push('No related parties were identified with confidence.');
7187
+ }
7188
+
7189
+ return {
7190
+ ...draft,
7191
+ summary: draft.summary || draft.description || draft.name,
7192
+ missingFields: Array.from(new Set(missingFields.filter(Boolean))),
7193
+ warnings: Array.from(new Set(warnings.filter(Boolean))),
7194
+ };
7195
+ }
7196
+
7197
+ private normalizeExtractionString(value: unknown) {
7198
+ return typeof value === 'string'
7199
+ ? value.trim()
7200
+ : value === null || value === undefined
7201
+ ? ''
7202
+ : String(value).trim();
7203
+ }
7204
+
7205
+ private normalizeExtractionBoolean(value: unknown) {
7206
+ if (typeof value === 'boolean') {
7207
+ return value;
7208
+ }
7209
+
7210
+ const normalized = this.normalizeExtractionString(value).toLowerCase();
7211
+ return normalized === 'true' || normalized === '1' || normalized === 'yes';
7212
+ }
7213
+
7214
+ private normalizeExtractionNumber(value: unknown) {
7215
+ if (typeof value === 'number' && Number.isFinite(value)) {
7216
+ return value;
7217
+ }
7218
+
7219
+ const normalized = this.normalizeExtractionString(value);
7220
+ if (!normalized) {
7221
+ return '';
7222
+ }
7223
+
7224
+ const direct = Number(normalized);
7225
+ if (Number.isFinite(direct)) {
7226
+ return direct;
7227
+ }
7228
+
7229
+ const ptBr = normalized
7230
+ .split('.')
7231
+ .join('')
7232
+ .replace(',', '.');
7233
+ const parsed = Number(ptBr);
7234
+
7235
+ return Number.isFinite(parsed) ? parsed : normalized;
7236
+ }
7237
+
7238
+ private normalizeExtractionDate(value: unknown) {
7239
+ const normalized = this.normalizeExtractionString(value);
7240
+ if (!normalized) {
7241
+ return '';
7242
+ }
7243
+
7244
+ const match = normalized.match(/\d{4}-\d{2}-\d{2}/);
7245
+ return match?.[0] ?? '';
7246
+ }
7247
+
7248
+ private normalizeStringList(value: unknown) {
7249
+ return Array.isArray(value)
7250
+ ? value
7251
+ .map((item) => this.normalizeExtractionString(item))
7252
+ .filter(Boolean)
7253
+ : [];
7254
+ }
7255
+
7256
+ private normalizeObjectList(value: unknown): Record<string, unknown>[] {
7257
+ if (!Array.isArray(value)) {
7258
+ return [];
7259
+ }
7260
+
7261
+ return value.filter(
7262
+ (item): item is Record<string, unknown> =>
7263
+ Boolean(item) && typeof item === 'object'
7264
+ );
7265
+ }
7266
+
7267
+ private normalizeEnumValue<T extends string>(
7268
+ value: unknown,
7269
+ allowed: readonly T[],
7270
+ fallback: T
7271
+ ): T {
7272
+ const normalized = this.normalizeExtractionString(value) as T;
7273
+ return allowed.includes(normalized) ? normalized : fallback;
7274
+ }
7275
+
7276
+ private normalizeContractExtractionMimeType(
7277
+ mimeType?: string | null,
7278
+ fileName?: string | null
7279
+ ) {
7280
+ const current = this.normalizeExtractionString(mimeType).toLowerCase();
7281
+ if (current && current !== 'application/octet-stream') {
7282
+ return current;
7283
+ }
7284
+
7285
+ const name = this.normalizeExtractionString(fileName).toLowerCase();
7286
+ if (name.endsWith('.pdf')) return 'application/pdf';
7287
+ if (name.endsWith('.doc')) return 'application/msword';
7288
+ if (name.endsWith('.docx')) {
7289
+ return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
7290
+ }
7291
+
7292
+ return current || 'application/octet-stream';
7293
+ }
7294
+
7295
+ private isAllowedContractExtractionMimeType(mimeType: string) {
7296
+ return [
7297
+ 'application/pdf',
7298
+ 'application/msword',
7299
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
7300
+ ].includes(mimeType);
7301
+ }
7302
+
3522
7303
  private requireFields(input: Record<string, unknown>, required: string[]) {
3523
7304
  for (const field of required) {
3524
7305
  const value = input[field];
@@ -3553,16 +7334,20 @@ export class OperationsService {
3553
7334
  updates: string[],
3554
7335
  params: unknown[],
3555
7336
  column: string,
3556
- value: unknown
7337
+ value: unknown,
7338
+ castType?: string
3557
7339
  ) {
3558
7340
  if (value === undefined) return;
3559
7341
  params.push(value);
3560
- updates.push(`${column} = $${params.length}`);
7342
+ const placeholder = castType
7343
+ ? `$${params.length}::${castType}`
7344
+ : `$${params.length}`;
7345
+ updates.push(`${column} = ${placeholder}`);
3561
7346
  }
3562
7347
 
3563
- private param(params: unknown[], value: unknown) {
7348
+ private param(params: unknown[], value: unknown, castType?: string) {
3564
7349
  params.push(value);
3565
- return `$${params.length}`;
7350
+ return castType ? `$${params.length}::${castType}` : `$${params.length}`;
3566
7351
  }
3567
7352
 
3568
7353
  private groupBy<T extends Record<string, any>>(items: T[], key: keyof T) {