-
Notifications
You must be signed in to change notification settings - Fork 276
Expand file tree
/
Copy pathvendors.service.ts
More file actions
648 lines (581 loc) · 19.7 KB
/
vendors.service.ts
File metadata and controls
648 lines (581 loc) · 19.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
import { BadRequestException, Injectable, NotFoundException, Logger } from '@nestjs/common';
import { db, TaskItemPriority, TaskItemStatus } from '@trycompai/db';
import { CreateVendorDto } from './dto/create-vendor.dto';
import { UpdateVendorDto } from './dto/update-vendor.dto';
import { tasks } from '@trigger.dev/sdk';
import { Prisma } from '@prisma/client';
import type { TriggerVendorRiskAssessmentVendorDto } from './dto/trigger-vendor-risk-assessment.dto';
import { resolveTaskCreatorAndAssignee } from '../trigger/vendor/vendor-risk-assessment/assignee';
const normalizeWebsite = (
website: string | null | undefined,
): string | null => {
if (!website) return null;
const trimmed = website.trim();
if (!trimmed) return null;
// Require explicit protocol (do not silently force https)
if (!/^https?:\/\//i.test(trimmed)) {
return null;
}
try {
const url = new URL(trimmed);
const protocol = url.protocol.toLowerCase();
const hostname = url.hostname.toLowerCase().replace(/^www\./, '');
const port = url.port ? `:${url.port}` : '';
return `${protocol}//${hostname}${port}`;
} catch {
return null;
}
};
/**
* Extract domain from website URL for GlobalVendors lookup.
* Removes www. prefix and returns just the domain (e.g., "example.com").
*/
const extractDomain = (website: string | null | undefined): string | null => {
if (!website) return null;
const trimmed = website.trim();
if (!trimmed) return null;
try {
// Add protocol if missing to make URL parsing work
const urlString = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
const url = new URL(urlString);
// Remove www. prefix and return just the domain
return url.hostname.toLowerCase().replace(/^www\./, '');
} catch {
return null;
}
};
const VERIFY_RISK_ASSESSMENT_TASK_TITLE = 'Verify risk assessment' as const;
@Injectable()
export class VendorsService {
private readonly logger = new Logger(VendorsService.name);
async searchGlobal(name: string) {
const whereClause = name.trim()
? {
OR: [
{
company_name: {
contains: name,
mode: 'insensitive' as const,
},
},
{ legal_name: { contains: name, mode: 'insensitive' as const } },
],
}
: {};
const vendors = await db.globalVendors.findMany({
where: whereClause,
take: 50,
orderBy: { company_name: 'asc' },
});
return { vendors };
}
async findAllByOrganization(organizationId: string) {
try {
const vendors = await db.vendor.findMany({
where: { organizationId },
orderBy: { createdAt: 'desc' },
include: {
assignee: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
});
this.logger.log(
`Retrieved ${vendors.length} vendors for organization ${organizationId}`,
);
return vendors;
} catch (error) {
this.logger.error(
`Failed to retrieve vendors for organization ${organizationId}:`,
error,
);
throw error;
}
}
async findById(id: string, organizationId: string) {
try {
const vendor = await db.vendor.findFirst({
where: {
id,
organizationId,
},
include: {
assignee: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
});
if (!vendor) {
throw new NotFoundException(
`Vendor with ID ${id} not found in organization ${organizationId}`,
);
}
// Fetch risk assessment from GlobalVendors if vendor has a website
const domain = extractDomain(vendor.website);
let globalVendorData: {
website: string;
riskAssessmentData: Prisma.JsonValue;
riskAssessmentVersion: string | null;
riskAssessmentUpdatedAt: Date | null;
} | null = null;
if (domain) {
const duplicates = await db.globalVendors.findMany({
where: {
website: {
contains: domain,
},
},
select: {
website: true,
riskAssessmentData: true,
riskAssessmentVersion: true,
riskAssessmentUpdatedAt: true,
},
orderBy: [{ riskAssessmentUpdatedAt: 'desc' }, { createdAt: 'desc' }],
});
// Prefer record WITH risk assessment data (most recent)
globalVendorData =
duplicates.find((gv) => gv.riskAssessmentData !== null) ??
duplicates[0] ??
null;
}
// Merge GlobalVendors risk assessment data into response
const vendorWithRiskAssessment = {
...vendor,
riskAssessmentData: globalVendorData?.riskAssessmentData ?? null,
riskAssessmentVersion: globalVendorData?.riskAssessmentVersion ?? null,
riskAssessmentUpdatedAt:
globalVendorData?.riskAssessmentUpdatedAt ?? null,
};
this.logger.log(`Retrieved vendor: ${vendor.name} (${id})`);
return vendorWithRiskAssessment;
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Failed to retrieve vendor ${id}:`, error);
throw error;
}
}
private async validateAssigneeNotPlatformAdmin(assigneeId: string, organizationId: string) {
const member = await db.member.findFirst({
where: { id: assigneeId, organizationId },
include: { user: { select: { isPlatformAdmin: true } } },
});
if (member?.user.isPlatformAdmin) {
throw new BadRequestException('Cannot assign a platform admin as assignee');
}
}
async create(
organizationId: string,
createVendorDto: CreateVendorDto,
createdByUserId?: string,
) {
try {
if (createVendorDto.assigneeId) {
await this.validateAssigneeNotPlatformAdmin(createVendorDto.assigneeId, organizationId);
}
const vendor = await db.vendor.create({
data: {
...createVendorDto,
organizationId,
},
});
this.logger.log(
`Created new vendor: ${vendor.name} (${vendor.id}) for organization ${organizationId}`,
);
// Trigger background task to research vendor and create risk assessment task
try {
const handle = await tasks.trigger('vendor-risk-assessment-task', {
vendorId: vendor.id,
vendorName: vendor.name,
vendorWebsite: vendor.website,
organizationId,
createdByUserId: createdByUserId || null,
});
this.logger.log(
`Triggered vendor risk assessment task (${handle.id}) for vendor ${vendor.id}`,
);
} catch (triggerError) {
// Don't fail vendor creation if task trigger fails
this.logger.error(
`Failed to trigger risk assessment task for vendor ${vendor.id}:`,
triggerError,
);
}
return vendor;
} catch (error) {
this.logger.error(
`Failed to create vendor for organization ${organizationId}:`,
error,
);
throw error;
}
}
/**
* Trigger a single vendor risk assessment and return run info for real-time tracking.
* Use this when you need the runId and publicAccessToken for client-side tracking.
*/
async triggerSingleVendorRiskAssessment(params: {
organizationId: string;
vendorId: string;
vendorName: string;
vendorWebsite: string | null;
createdByUserId?: string | null;
}): Promise<{ runId: string; publicAccessToken: string }> {
const {
organizationId,
vendorId,
vendorName,
vendorWebsite,
createdByUserId,
} = params;
const normalizedWebsite = normalizeWebsite(vendorWebsite);
if (!normalizedWebsite) {
throw new Error('Vendor website is missing or invalid');
}
const handle = await tasks.trigger('vendor-risk-assessment-task', {
vendorId,
vendorName,
vendorWebsite: normalizedWebsite,
organizationId,
createdByUserId: createdByUserId ?? null,
withResearch: true,
});
this.logger.log(`Triggered single vendor risk assessment task`, {
vendorId,
vendorName,
runId: handle.id,
});
return {
runId: handle.id,
publicAccessToken: handle.publicAccessToken,
};
}
async triggerVendorRiskAssessments(params: {
organizationId: string;
withResearch: boolean;
vendors: TriggerVendorRiskAssessmentVendorDto[];
}): Promise<{ triggered: number; batchId: string | null }> {
const { organizationId, withResearch, vendors } = params;
if (vendors.length === 0) {
this.logger.log('No vendors to trigger risk assessments for');
return { triggered: 0, batchId: null };
}
// If we are NOT forcing research, avoid triggering runs for vendors that already have
// GlobalVendors riskAssessmentData. (This keeps onboarding + UI creates cheap and quiet.)
let vendorsToTrigger = vendors;
let skippedBecauseAlreadyHasData = 0;
let skippedVendors: TriggerVendorRiskAssessmentVendorDto[] = [];
let createdVerifyTasks = 0;
let updatedVerifyTasks = 0;
if (!withResearch) {
// Extract domains for all vendors and check which ones already have risk assessment data
const vendorDomains = vendors
.map((v) => ({
vendor: v,
domain: extractDomain(v.vendorWebsite ?? null),
}))
.filter(
(
vd,
): vd is {
vendor: TriggerVendorRiskAssessmentVendorDto;
domain: string;
} => vd.domain !== null,
);
// Check which domains already have risk assessment data using contains filter
const existingDomains = new Set<string>();
if (vendorDomains.length > 0) {
const uniqueDomains = Array.from(
new Set(vendorDomains.map((vd) => vd.domain)),
);
const existing = await db.globalVendors.findMany({
where: {
OR: uniqueDomains.map((domain) => ({
website: { contains: domain },
})),
// Json fields require Prisma null sentinels (DbNull/JsonNull), not literal null
riskAssessmentData: { not: Prisma.DbNull },
},
select: { website: true },
});
// Extract domains from existing records to build the set
for (const gv of existing) {
const domain = extractDomain(gv.website);
if (domain) {
existingDomains.add(domain);
}
}
}
vendorsToTrigger = vendors.filter((v) => {
const domain = extractDomain(v.vendorWebsite ?? null);
if (!domain) return true; // Let the task handle "no website" skip behavior.
return !existingDomains.has(domain);
});
skippedVendors = vendors.filter((v) => {
const domain = extractDomain(v.vendorWebsite ?? null);
if (!domain) return false;
return existingDomains.has(domain);
});
skippedBecauseAlreadyHasData = vendors.length - vendorsToTrigger.length;
// For vendors we are skipping (because GlobalVendors already has data), still ensure the human
// "Verify risk assessment" task exists. This keeps the UI consistent without running any job.
if (skippedVendors.length > 0) {
const settled = await Promise.allSettled(
skippedVendors.map(async (v) => {
const { creatorMemberId, assigneeMemberId } =
await resolveTaskCreatorAndAssignee({
organizationId,
createdByUserId: null,
});
const creatorMember = await db.member.findUnique({
where: { id: creatorMemberId },
select: { id: true, userId: true },
});
const existingVerifyTask = await db.taskItem.findFirst({
where: {
organizationId,
entityType: 'vendor',
entityId: v.vendorId,
title: VERIFY_RISK_ASSESSMENT_TASK_TITLE,
},
select: { id: true, status: true },
orderBy: { createdAt: 'desc' },
});
if (!existingVerifyTask) {
const created = await db.taskItem.create({
data: {
title: VERIFY_RISK_ASSESSMENT_TASK_TITLE,
description:
'Review the latest Risk Assessment and confirm it is accurate.',
status: TaskItemStatus.todo,
priority: TaskItemPriority.high,
entityId: v.vendorId,
entityType: 'vendor',
organizationId,
createdById: creatorMemberId,
assigneeId: assigneeMemberId,
},
select: { id: true },
});
createdVerifyTasks += 1;
// Audit log (best-effort)
if (creatorMember?.userId) {
try {
await db.auditLog.create({
data: {
organizationId,
userId: creatorMember.userId,
memberId: creatorMember.id,
entityType: 'task',
entityId: created.id,
description: 'created this task',
data: {
action: 'created',
taskItemId: created.id,
taskTitle: VERIFY_RISK_ASSESSMENT_TASK_TITLE,
parentEntityType: 'vendor',
parentEntityId: v.vendorId,
},
},
});
} catch {
// ignore
}
}
return;
}
// If it exists but is blocked, flip it to todo (unless already done/canceled)
if (existingVerifyTask.status === TaskItemStatus.in_progress) {
await db.taskItem.update({
where: { id: existingVerifyTask.id },
data: {
status: TaskItemStatus.todo,
description:
'Review the latest Risk Assessment and confirm it is accurate.',
assigneeId: assigneeMemberId,
updatedById: creatorMemberId,
},
});
updatedVerifyTasks += 1;
}
}),
);
const failures = settled.filter((r) => r.status === 'rejected');
if (failures.length > 0) {
this.logger.warn(
'Some verify tasks could not be ensured for skipped vendors',
{
organizationId,
failures: failures.length,
skippedCount: skippedVendors.length,
},
);
}
}
}
// Simplified logging: clear lists of what needs research vs what doesn't
if (!withResearch && skippedVendors.length > 0) {
this.logger.log(
'✅ Vendors that DO NOT need research (already have data)',
{
count: skippedVendors.length,
vendors: skippedVendors.map(
(v) => `${v.vendorName} (${v.vendorWebsite ?? 'no website'})`,
),
},
);
}
if (vendorsToTrigger.length > 0) {
this.logger.log('🔍 Vendors that NEED research (missing data)', {
count: vendorsToTrigger.length,
withResearch,
vendors: vendorsToTrigger.map(
(v) => `${v.vendorName} (${v.vendorWebsite ?? 'no website'})`,
),
});
} else {
this.logger.log(
'✅ All vendors already have risk assessment data - no research needed',
{
totalVendors: vendors.length,
},
);
}
// Use batchTrigger for efficiency (less overhead than N individual triggers)
// If we're triggering the task, it means research is needed (we've already filtered)
// So always pass withResearch: true when triggering
const batch = vendorsToTrigger.map((v) => ({
payload: {
vendorId: v.vendorId,
vendorName: v.vendorName,
// Keep website canonical so downstream (Trigger task) uses the same GlobalVendors key.
vendorWebsite: normalizeWebsite(v.vendorWebsite ?? null),
organizationId,
createdByUserId: null,
withResearch: true, // Always true - if task is triggered, research is needed
},
}));
try {
if (vendorsToTrigger.length === 0) {
return { triggered: 0, batchId: null };
}
const batchHandle = await tasks.batchTrigger(
'vendor-risk-assessment-task',
batch,
);
this.logger.log('✅ Triggered risk assessment tasks', {
count: vendorsToTrigger.length,
batchId: batchHandle.batchId,
});
return {
triggered: vendorsToTrigger.length,
batchId: batchHandle.batchId,
};
} catch (error) {
this.logger.error(
'Failed to batch trigger vendor risk assessment tasks',
{
organizationId,
vendorCount: vendorsToTrigger.length,
error: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
},
);
throw error;
}
}
/**
* Trigger a vendor risk assessment from a public endpoint.
* Looks up the vendor, triggers the assessment, and updates vendor status.
*/
async triggerAssessment(
vendorId: string,
organizationId: string,
userId?: string,
): Promise<{ runId: string; publicAccessToken: string }> {
const vendor = await this.findById(vendorId, organizationId);
const result = await this.triggerSingleVendorRiskAssessment({
organizationId,
vendorId: vendor.id,
vendorName: vendor.name,
vendorWebsite: vendor.website,
createdByUserId: userId ?? null,
});
// Update vendor status to in_progress
await db.vendor.update({
where: { id: vendor.id },
data: { status: 'in_progress' },
});
return result;
}
async updateById(
id: string,
organizationId: string,
updateVendorDto: UpdateVendorDto,
) {
try {
// First check if the vendor exists in the organization
await this.findById(id, organizationId);
if (updateVendorDto.assigneeId) {
await this.validateAssigneeNotPlatformAdmin(updateVendorDto.assigneeId, organizationId);
}
const updatedVendor = await db.vendor.update({
where: { id },
data: updateVendorDto,
});
this.logger.log(`Updated vendor: ${updatedVendor.name} (${id})`);
return updatedVendor;
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Failed to update vendor ${id}:`, error);
throw error;
}
}
async deleteById(id: string, organizationId: string) {
try {
// First check if the vendor exists in the organization
const existingVendor = await this.findById(id, organizationId);
await db.vendor.delete({
where: { id },
});
this.logger.log(`Deleted vendor: ${existingVendor.name} (${id})`);
return {
message: 'Vendor deleted successfully',
deletedVendor: {
id: existingVendor.id,
name: existingVendor.name,
},
};
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Failed to delete vendor ${id}:`, error);
throw error;
}
}
}