@hed-hog/operations 0.0.300 → 0.0.301

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