@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
@@ -2,6 +2,15 @@ columns:
2
2
  - type: pk
3
3
  - name: user_id
4
4
  type: int
5
+ isNullable: true
6
+ - name: person_id
7
+ type: fk
8
+ isNullable: true
9
+ references:
10
+ table: person
11
+ column: id
12
+ onDelete: SET NULL
13
+ onUpdate: CASCADE
5
14
  - name: supervisor_collaborator_id
6
15
  type: fk
7
16
  isNullable: true
@@ -24,6 +33,14 @@ columns:
24
33
  type: varchar
25
34
  length: 120
26
35
  isNullable: true
36
+ - name: department_id
37
+ type: fk
38
+ isNullable: true
39
+ references:
40
+ table: operations_department
41
+ column: id
42
+ onDelete: SET NULL
43
+ onUpdate: CASCADE
27
44
  - name: title
28
45
  type: varchar
29
46
  length: 120
@@ -59,9 +76,12 @@ columns:
59
76
  indices:
60
77
  - columns: [user_id]
61
78
  isUnique: true
79
+ - columns: [person_id]
80
+ isUnique: true
62
81
  - columns: [code]
63
82
  isUnique: true
64
83
  - columns: [collaborator_type]
65
84
  - columns: [supervisor_collaborator_id]
85
+ - columns: [department_id]
66
86
  - columns: [status]
67
87
  - columns: [deleted_at]
@@ -6,6 +6,7 @@ columns:
6
6
  - name: name
7
7
  type: varchar
8
8
  length: 180
9
+ isNullable: true
9
10
  - name: contract_category
10
11
  type: enum
11
12
  values: [employee, contractor, client, supplier, vendor, partner, internal, other]
@@ -17,6 +18,7 @@ columns:
17
18
  - name: client_name
18
19
  type: varchar
19
20
  length: 180
21
+ isNullable: true
20
22
  - name: signature_status
21
23
  type: enum
22
24
  values: [not_started, pending, partially_signed, signed, expired]
@@ -44,15 +46,24 @@ columns:
44
46
  column: id
45
47
  onDelete: SET NULL
46
48
  onUpdate: CASCADE
49
+ - name: contract_template_id
50
+ type: fk
51
+ isNullable: true
52
+ references:
53
+ table: operations_contract_template
54
+ column: id
55
+ onDelete: SET NULL
56
+ onUpdate: CASCADE
47
57
  - name: origin_type
48
58
  type: enum
49
- values: [manual, employee_hiring, client_project]
59
+ values: [manual, employee_hiring, client_project, crm_proposal]
50
60
  default: manual
51
61
  - name: origin_id
52
62
  type: int
53
63
  isNullable: true
54
64
  - name: start_date
55
65
  type: date
66
+ isNullable: true
56
67
  - name: end_date
57
68
  type: date
58
69
  isNullable: true
@@ -74,6 +85,13 @@ columns:
74
85
  type: enum
75
86
  values: [draft, under_review, active, renewal, expired, closed, archived]
76
87
  default: draft
88
+ - name: creation_mode
89
+ type: enum
90
+ values: [blank, template, upload, duplicate]
91
+ default: blank
92
+ - name: wizard_step
93
+ type: int
94
+ default: 0
77
95
  - name: description
78
96
  type: text
79
97
  isNullable: true
@@ -97,6 +115,7 @@ indices:
97
115
  isUnique: true
98
116
  - columns: [account_manager_collaborator_id]
99
117
  - columns: [related_collaborator_id]
118
+ - columns: [contract_template_id]
100
119
  - columns: [contract_category]
101
120
  - columns: [contract_type]
102
121
  - columns: [origin_type]
@@ -104,6 +123,8 @@ indices:
104
123
  - columns: [signature_status]
105
124
  - columns: [is_active]
106
125
  - columns: [status]
126
+ - columns: [creation_mode]
127
+ - columns: [wizard_step]
107
128
  - columns: [start_date]
108
129
  - columns: [end_date]
109
130
  - columns: [deleted_at]
@@ -7,25 +7,40 @@ columns:
7
7
  column: id
8
8
  onDelete: CASCADE
9
9
  onUpdate: CASCADE
10
- - name: document_type
11
- type: enum
12
- values: [uploaded_pdf, generated_pdf, attachment, other]
13
- default: attachment
14
- - name: file_name
15
- type: varchar
16
- length: 200
10
+ - name: document_type
11
+ type: enum
12
+ values: [source_upload, generated_pdf, attachment, other]
13
+ default: attachment
14
+ - name: file_id
15
+ type: fk
16
+ isNullable: true
17
+ references:
18
+ table: file
19
+ column: id
20
+ onDelete: SET NULL
21
+ onUpdate: CASCADE
22
+ - name: file_name
23
+ type: varchar
24
+ length: 200
17
25
  - name: mime_type
18
26
  type: varchar
19
27
  length: 120
20
28
  - name: file_content_base64
21
29
  type: text
22
30
  isNullable: true
23
- - name: is_current
24
- type: boolean
25
- default: true
26
- - name: notes
27
- type: text
28
- isNullable: true
31
+ - name: is_current
32
+ type: boolean
33
+ default: true
34
+ - name: extraction_status
35
+ type: enum
36
+ values: [pending, processing, completed, failed, skipped]
37
+ default: skipped
38
+ - name: extraction_summary
39
+ type: text
40
+ isNullable: true
41
+ - name: notes
42
+ type: text
43
+ isNullable: true
29
44
  - name: deleted_at
30
45
  type: datetime
31
46
  isNullable: true
@@ -34,6 +49,8 @@ columns:
34
49
 
35
50
  indices:
36
51
  - columns: [contract_id]
37
- - columns: [document_type]
38
- - columns: [is_current]
39
- - columns: [deleted_at]
52
+ - columns: [document_type]
53
+ - columns: [file_id]
54
+ - columns: [is_current]
55
+ - columns: [extraction_status]
56
+ - columns: [deleted_at]
@@ -0,0 +1,58 @@
1
+ columns:
2
+ - type: pk
3
+ - type: slug
4
+ - name: code
5
+ type: varchar
6
+ length: 40
7
+ isNullable: true
8
+ - name: name
9
+ type: varchar
10
+ length: 180
11
+ - name: description
12
+ type: text
13
+ isNullable: true
14
+ - name: contract_category
15
+ type: enum
16
+ values: [employee, contractor, client, supplier, vendor, partner, internal, other]
17
+ default: client
18
+ - name: contract_type
19
+ type: enum
20
+ values: [clt, pj, freelancer_agreement, service_agreement, fixed_term, recurring_service, nda, amendment, addendum, other]
21
+ default: service_agreement
22
+ - name: billing_model
23
+ type: enum
24
+ values: [time_and_material, monthly_retainer, fixed_price]
25
+ default: time_and_material
26
+ - name: signature_status
27
+ type: enum
28
+ values: [not_started, pending, partially_signed, signed, expired]
29
+ default: not_started
30
+ - name: is_active
31
+ type: boolean
32
+ default: true
33
+ - name: status
34
+ type: enum
35
+ values: [draft, active, inactive, archived]
36
+ default: active
37
+ - name: content_html
38
+ type: text
39
+ isNullable: true
40
+ - name: deleted_at
41
+ type: datetime
42
+ isNullable: true
43
+ - type: created_at
44
+ - type: updated_at
45
+
46
+ indices:
47
+ - columns: [slug]
48
+ isUnique: true
49
+ - columns: [code]
50
+ isUnique: true
51
+ - columns: [name]
52
+ - columns: [contract_category]
53
+ - columns: [contract_type]
54
+ - columns: [billing_model]
55
+ - columns: [signature_status]
56
+ - columns: [is_active]
57
+ - columns: [status]
58
+ - columns: [deleted_at]
@@ -0,0 +1,24 @@
1
+ columns:
2
+ - type: pk
3
+ - type: slug
4
+ - name: code
5
+ type: varchar
6
+ length: 32
7
+ isNullable: true
8
+ - name: name
9
+ type: varchar
10
+ length: 120
11
+ - name: description
12
+ type: text
13
+ isNullable: true
14
+ - name: deleted_at
15
+ type: datetime
16
+ isNullable: true
17
+ - type: created_at
18
+ - type: updated_at
19
+
20
+ indices:
21
+ - columns: [code]
22
+ isUnique: true
23
+ - columns: [name]
24
+ - columns: [deleted_at]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/operations",
3
- "version": "0.0.300",
3
+ "version": "0.0.301",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -10,11 +10,12 @@
10
10
  "@nestjs/jwt": "^11",
11
11
  "@nestjs/mapped-types": "*",
12
12
  "@hed-hog/api": "0.0.6",
13
- "@hed-hog/api-pagination": "0.0.7",
14
13
  "@hed-hog/api-types": "0.0.1",
15
- "@hed-hog/api-locale": "0.0.14",
16
14
  "@hed-hog/api-prisma": "0.0.6",
17
- "@hed-hog/core": "0.0.300"
15
+ "@hed-hog/api-locale": "0.0.14",
16
+ "@hed-hog/api-pagination": "0.0.7",
17
+ "@hed-hog/core": "0.0.301",
18
+ "@hed-hog/contact": "0.0.301"
18
19
  },
19
20
  "exports": {
20
21
  ".": {
@@ -30,6 +31,7 @@
30
31
  ],
31
32
  "scripts": {
32
33
  "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
34
+ "test": "jest --config jest.config.ts --runInBand",
33
35
  "prebuild": "pnpm --dir ../.. exec ts-node ./scripts/build-dependencies.ts libraries/operations",
34
36
  "build": "tsc --project tsconfig.production.json",
35
37
  "patch": "pnpm exec ts-node ../../scripts/patch.ts libraries/operations",
@@ -2,6 +2,7 @@ import { Role, User } from '@hed-hog/api';
2
2
  import {
3
3
  Body,
4
4
  Controller,
5
+ Delete,
5
6
  Get,
6
7
  Param,
7
8
  ParseIntPipe,
@@ -61,6 +62,25 @@ export class OperationsController {
61
62
  );
62
63
  }
63
64
 
65
+ @Get('departments')
66
+ listDepartments(@User() user) {
67
+ return this.operationsService.listDepartments(Number(user?.id || 0));
68
+ }
69
+
70
+ @Post('departments')
71
+ createDepartment(@User() user, @Body() data) {
72
+ return this.operationsService.createDepartment(Number(user?.id || 0), data);
73
+ }
74
+
75
+ @Patch('departments/:id')
76
+ updateDepartment(
77
+ @User() user,
78
+ @Param('id', ParseIntPipe) id: number,
79
+ @Body() data
80
+ ) {
81
+ return this.operationsService.updateDepartment(Number(user?.id || 0), id, data);
82
+ }
83
+
64
84
  @Get('projects')
65
85
  listProjects(@User() user) {
66
86
  return this.operationsService.listProjects(Number(user?.id || 0));
@@ -81,6 +101,40 @@ export class OperationsController {
81
101
  return this.operationsService.updateProject(Number(user?.id || 0), id, data);
82
102
  }
83
103
 
104
+ @Get('contract-templates')
105
+ listContractTemplates(@User() user) {
106
+ return this.operationsService.listContractTemplates(Number(user?.id || 0));
107
+ }
108
+
109
+ @Get('contract-templates/:id')
110
+ getContractTemplate(@User() user, @Param('id', ParseIntPipe) id: number) {
111
+ return this.operationsService.getContractTemplateById(
112
+ Number(user?.id || 0),
113
+ id
114
+ );
115
+ }
116
+
117
+ @Post('contract-templates')
118
+ createContractTemplate(@User() user, @Body() data) {
119
+ return this.operationsService.createContractTemplate(
120
+ Number(user?.id || 0),
121
+ data
122
+ );
123
+ }
124
+
125
+ @Patch('contract-templates/:id')
126
+ updateContractTemplate(
127
+ @User() user,
128
+ @Param('id', ParseIntPipe) id: number,
129
+ @Body() data
130
+ ) {
131
+ return this.operationsService.updateContractTemplate(
132
+ Number(user?.id || 0),
133
+ id,
134
+ data
135
+ );
136
+ }
137
+
84
138
  @Get('contracts')
85
139
  listContracts(@User() user) {
86
140
  return this.operationsService.listContracts(Number(user?.id || 0));
@@ -91,11 +145,63 @@ export class OperationsController {
91
145
  return this.operationsService.getContractById(Number(user?.id || 0), id);
92
146
  }
93
147
 
148
+ @Post('contracts/drafts')
149
+ createContractDraft(@User() user, @Body() data) {
150
+ return this.operationsService.createContractDraft(Number(user?.id || 0), data);
151
+ }
152
+
94
153
  @Post('contracts')
95
154
  createContract(@User() user, @Body() data) {
96
155
  return this.operationsService.createContract(Number(user?.id || 0), data);
97
156
  }
98
157
 
158
+ @Post('contracts/extract-draft')
159
+ extractContractDraft(@User() user, @Body() data) {
160
+ return this.operationsService.extractContractDraft(
161
+ Number(user?.id || 0),
162
+ data
163
+ );
164
+ }
165
+
166
+ @Post('contracts/:id/extract-source')
167
+ extractContractSource(
168
+ @User() user,
169
+ @Param('id', ParseIntPipe) id: number,
170
+ @Body() data
171
+ ) {
172
+ return this.operationsService.extractContractSource(
173
+ Number(user?.id || 0),
174
+ id,
175
+ data
176
+ );
177
+ }
178
+
179
+ @Post('contracts/:id/generate-content')
180
+ generateContractContent(
181
+ @User() user,
182
+ @Param('id', ParseIntPipe) id: number,
183
+ @Body() data
184
+ ) {
185
+ return this.operationsService.generateContractContent(
186
+ Number(user?.id || 0),
187
+ id,
188
+ data
189
+ );
190
+ }
191
+
192
+ @Post('contracts/:id/legal-review')
193
+ reviewContractLegally(
194
+ @User() user,
195
+ @Param('id', ParseIntPipe) id: number,
196
+ @Body() data
197
+ ) {
198
+ return this.operationsService.reviewContractLegally(
199
+ Number(user?.id || 0),
200
+ id,
201
+ data
202
+ );
203
+ }
204
+
99
205
  @Patch('contracts/:id')
100
206
  updateContract(
101
207
  @User() user,
@@ -105,6 +211,22 @@ export class OperationsController {
105
211
  return this.operationsService.updateContract(Number(user?.id || 0), id, data);
106
212
  }
107
213
 
214
+ @Delete('contracts/:id')
215
+ removeContract(@User() user, @Param('id', ParseIntPipe) id: number) {
216
+ return this.operationsService.removeContract(Number(user?.id || 0), id);
217
+ }
218
+
219
+ @Post('contracts/:id/generate-pdf')
220
+ generateContractPdf(
221
+ @User() user,
222
+ @Param('id', ParseIntPipe) id: number
223
+ ) {
224
+ return this.operationsService.generateContractPdf(
225
+ Number(user?.id || 0),
226
+ id
227
+ );
228
+ }
229
+
108
230
  @Get('timesheets')
109
231
  listTimesheets(@User() user) {
110
232
  return this.operationsService.listTimesheets(Number(user?.id || 0));
@@ -1,10 +1,11 @@
1
1
  import { LocaleModule } from '@hed-hog/api-locale';
2
2
  import { PaginationModule } from '@hed-hog/api-pagination';
3
3
  import { PrismaModule } from '@hed-hog/api-prisma';
4
- import { IntegrationModule } from '@hed-hog/core';
4
+ import { AiModule, FileModule, IntegrationModule, SettingModule } from '@hed-hog/core';
5
5
  import { forwardRef, Module } from '@nestjs/common';
6
6
  import { ConfigModule } from '@nestjs/config';
7
7
  import { OperationsController } from './operations.controller';
8
+ import { OperationsProposalSubscriber } from './operations.proposal.subscriber';
8
9
  import { OperationsService } from './operations.service';
9
10
 
10
11
  @Module({
@@ -13,10 +14,13 @@ import { OperationsService } from './operations.service';
13
14
  forwardRef(() => PaginationModule),
14
15
  forwardRef(() => PrismaModule),
15
16
  forwardRef(() => LocaleModule),
17
+ forwardRef(() => AiModule),
18
+ forwardRef(() => FileModule),
16
19
  forwardRef(() => IntegrationModule),
20
+ forwardRef(() => SettingModule),
17
21
  ],
18
22
  controllers: [OperationsController],
19
- providers: [OperationsService],
23
+ providers: [OperationsService, OperationsProposalSubscriber],
20
24
  exports: [OperationsService],
21
25
  })
22
26
  export class OperationsModule {}
@@ -0,0 +1,121 @@
1
+ /// <reference types="jest" />
2
+
3
+ import { OperationsProposalSubscriber } from './operations.proposal.subscriber';
4
+
5
+ describe('OperationsProposalSubscriber', () => {
6
+ let integrationApi: {
7
+ subscribeMany: jest.Mock;
8
+ findLinksBySource: jest.Mock;
9
+ createLink: jest.Mock;
10
+ };
11
+ let operationsService: {
12
+ createContractFromProposalIntegration: jest.Mock;
13
+ };
14
+ let subscriber: OperationsProposalSubscriber;
15
+
16
+ beforeEach(() => {
17
+ integrationApi = {
18
+ subscribeMany: jest.fn(),
19
+ findLinksBySource: jest.fn(),
20
+ createLink: jest.fn().mockResolvedValue(undefined),
21
+ };
22
+
23
+ operationsService = {
24
+ createContractFromProposalIntegration: jest
25
+ .fn()
26
+ .mockResolvedValue({ id: 88, code: 'CTR-088' }),
27
+ };
28
+
29
+ subscriber = new OperationsProposalSubscriber(
30
+ integrationApi as any,
31
+ operationsService as any,
32
+ );
33
+ });
34
+
35
+ afterEach(() => {
36
+ jest.clearAllMocks();
37
+ });
38
+
39
+ it('registers handlers for the proposal approval and conversion flow', () => {
40
+ subscriber.onModuleInit();
41
+
42
+ const subscriptions = integrationApi.subscribeMany.mock.calls[0][0];
43
+
44
+ expect(subscriptions).toHaveLength(2);
45
+ expect(subscriptions.map((entry: { eventName: string }) => entry.eventName)).toEqual(
46
+ expect.arrayContaining([
47
+ 'contact.proposal.convert_requested',
48
+ 'contact.proposal.approved',
49
+ ]),
50
+ );
51
+ });
52
+
53
+ it('skips duplicate contract generation when the integration link already exists', async () => {
54
+ subscriber.onModuleInit();
55
+
56
+ const subscriptions = integrationApi.subscribeMany.mock.calls[0][0];
57
+ const convertRequested = subscriptions.find(
58
+ (entry: { eventName: string }) => entry.eventName === 'contact.proposal.convert_requested',
59
+ );
60
+
61
+ integrationApi.findLinksBySource.mockResolvedValue([
62
+ {
63
+ targetModule: 'operations',
64
+ targetEntityType: 'contract',
65
+ targetEntityId: '88',
66
+ },
67
+ ]);
68
+
69
+ await convertRequested.handler({
70
+ eventName: 'contact.proposal.convert_requested',
71
+ sourceModule: 'contact',
72
+ aggregateType: 'proposal',
73
+ aggregateId: '1001',
74
+ payload: {
75
+ proposalId: 1001,
76
+ },
77
+ });
78
+
79
+ expect(operationsService.createContractFromProposalIntegration).not.toHaveBeenCalled();
80
+ expect(integrationApi.createLink).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it('creates the integration link after generating a contract draft', async () => {
84
+ subscriber.onModuleInit();
85
+
86
+ const subscriptions = integrationApi.subscribeMany.mock.calls[0][0];
87
+ const approved = subscriptions.find(
88
+ (entry: { eventName: string }) => entry.eventName === 'contact.proposal.approved',
89
+ );
90
+
91
+ integrationApi.findLinksBySource.mockResolvedValue([]);
92
+
93
+ await approved.handler({
94
+ eventName: 'contact.proposal.approved',
95
+ sourceModule: 'contact',
96
+ aggregateType: 'proposal',
97
+ aggregateId: '1002',
98
+ payload: {
99
+ proposalId: 1002,
100
+ },
101
+ });
102
+
103
+ expect(operationsService.createContractFromProposalIntegration).toHaveBeenCalledWith({
104
+ proposalId: 1002,
105
+ });
106
+ expect(integrationApi.createLink).toHaveBeenCalledWith(
107
+ expect.objectContaining({
108
+ sourceModule: 'contact',
109
+ sourceEntityType: 'proposal',
110
+ sourceEntityId: '1002',
111
+ targetModule: 'operations',
112
+ targetEntityType: 'contract',
113
+ targetEntityId: '88',
114
+ metadata: expect.objectContaining({
115
+ eventName: 'contact.proposal.approved',
116
+ contractCode: 'CTR-088',
117
+ }),
118
+ }),
119
+ );
120
+ });
121
+ });
@@ -0,0 +1,86 @@
1
+ import {
2
+ IntegrationDeveloperApiService,
3
+ LinkType,
4
+ } from '@hed-hog/core';
5
+ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
6
+ import { OperationsService } from './operations.service';
7
+
8
+ @Injectable()
9
+ export class OperationsProposalSubscriber implements OnModuleInit {
10
+ private readonly logger = new Logger(OperationsProposalSubscriber.name);
11
+
12
+ constructor(
13
+ private readonly integrationApi: IntegrationDeveloperApiService,
14
+ private readonly operationsService: OperationsService,
15
+ ) {}
16
+
17
+ onModuleInit(): void {
18
+ const handler = async (event) => {
19
+ const payload = (event.payload || {}) as {
20
+ proposalId?: number;
21
+ };
22
+ const sourceEntityType =
23
+ String(event.aggregateType || '').trim() || 'proposal';
24
+ const sourceEntityId =
25
+ String(payload.proposalId || event.aggregateId || '').trim() ||
26
+ event.aggregateId;
27
+
28
+ if (!sourceEntityId) {
29
+ throw new Error(`Missing proposal aggregate id for ${event.eventName}.`);
30
+ }
31
+
32
+ const existingLinks = await this.integrationApi.findLinksBySource({
33
+ module: event.sourceModule,
34
+ entityType: sourceEntityType,
35
+ entityId: sourceEntityId,
36
+ });
37
+
38
+ const alreadyLinked = existingLinks.some(
39
+ (link) =>
40
+ link.targetModule === 'operations' &&
41
+ link.targetEntityType === 'contract',
42
+ );
43
+
44
+ if (alreadyLinked) {
45
+ this.logger.debug(
46
+ `Skipping duplicate contract generation for ${sourceEntityType}:${sourceEntityId} from ${event.eventName}`,
47
+ );
48
+ return;
49
+ }
50
+
51
+ const createdContract =
52
+ await this.operationsService.createContractFromProposalIntegration(
53
+ event.payload || {},
54
+ );
55
+
56
+ await this.integrationApi.createLink({
57
+ sourceModule: event.sourceModule,
58
+ sourceEntityType,
59
+ sourceEntityId,
60
+ targetModule: 'operations',
61
+ targetEntityType: 'contract',
62
+ targetEntityId: String(createdContract.id),
63
+ linkType: LinkType.REFERENCE,
64
+ metadata: {
65
+ eventName: event.eventName,
66
+ contractCode: createdContract.code,
67
+ },
68
+ });
69
+ };
70
+
71
+ this.integrationApi.subscribeMany([
72
+ {
73
+ eventName: 'contact.proposal.convert_requested',
74
+ consumerName: 'operations.contract-from-proposal',
75
+ priority: 10,
76
+ handler,
77
+ },
78
+ {
79
+ eventName: 'contact.proposal.approved',
80
+ consumerName: 'operations.contract-from-proposal-legacy',
81
+ priority: 5,
82
+ handler,
83
+ },
84
+ ]);
85
+ }
86
+ }