Skip to content

Commit 68a600d

Browse files
committed
chore: merge main into release for new releases
2 parents d825d60 + 0b6cf11 commit 68a600d

File tree

36 files changed

+1435
-213
lines changed

36 files changed

+1435
-213
lines changed

apps/api/src/comments/comment-mention-notifier.service.ts

Lines changed: 182 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,168 @@ function extractMentionedUserIds(content: string | null): string[] {
3030
}
3131
import { CommentEntityType } from '@db';
3232

33+
function getAppBaseUrl(): string {
34+
return (
35+
process.env.NEXT_PUBLIC_APP_URL ??
36+
process.env.BETTER_AUTH_URL ??
37+
'https://app.trycomp.ai'
38+
);
39+
}
40+
41+
function getAllowedOrigins(): string[] {
42+
const candidates = [
43+
process.env.NEXT_PUBLIC_APP_URL,
44+
process.env.BETTER_AUTH_URL,
45+
'https://app.trycomp.ai',
46+
].filter(Boolean) as string[];
47+
48+
const origins = new Set<string>();
49+
for (const candidate of candidates) {
50+
try {
51+
origins.add(new URL(candidate).origin);
52+
} catch {
53+
// ignore invalid env values
54+
}
55+
}
56+
57+
return [...origins];
58+
}
59+
60+
function tryNormalizeContextUrl(params: {
61+
organizationId: string;
62+
contextUrl?: string;
63+
}): string | null {
64+
const { organizationId, contextUrl } = params;
65+
if (!contextUrl) return null;
66+
67+
try {
68+
const url = new URL(contextUrl);
69+
const allowedOrigins = new Set(getAllowedOrigins());
70+
if (!allowedOrigins.has(url.origin)) return null;
71+
72+
// Ensure the URL is for the same org so we don't accidentally deep-link elsewhere.
73+
// Use startsWith to prevent path traversal attacks (e.g., /attacker_org/victim_org/)
74+
if (!url.pathname.startsWith(`/${organizationId}/`)) return null;
75+
76+
return url.toString();
77+
} catch {
78+
return null;
79+
}
80+
}
81+
82+
async function buildFallbackCommentContext(params: {
83+
organizationId: string;
84+
entityType: CommentEntityType;
85+
entityId: string;
86+
}): Promise<{
87+
entityName: string;
88+
entityRoutePath: string;
89+
commentUrl: string;
90+
} | null> {
91+
const { organizationId, entityType, entityId } = params;
92+
const appUrl = getAppBaseUrl();
93+
94+
if (entityType === CommentEntityType.task) {
95+
// CommentEntityType.task can be:
96+
// - TaskItem id (preferred)
97+
// - Task id (legacy)
98+
// Use findFirst with organizationId to ensure entity belongs to correct org
99+
const taskItem = await db.taskItem.findFirst({
100+
where: { id: entityId, organizationId },
101+
select: { title: true, entityType: true, entityId: true },
102+
});
103+
104+
if (taskItem) {
105+
const parentRoutePath = taskItem.entityType === 'vendor' ? 'vendors' : 'risk';
106+
const url = new URL(
107+
`${appUrl}/${organizationId}/${parentRoutePath}/${taskItem.entityId}`,
108+
);
109+
url.searchParams.set('taskItemId', entityId);
110+
url.hash = 'task-items';
111+
112+
return {
113+
entityName: taskItem.title || 'Task',
114+
entityRoutePath: parentRoutePath,
115+
commentUrl: url.toString(),
116+
};
117+
}
118+
119+
const task = await db.task.findFirst({
120+
where: { id: entityId, organizationId },
121+
select: { title: true },
122+
});
123+
124+
if (!task) {
125+
// Entity not found in this organization - do not send notification
126+
return null;
127+
}
128+
129+
const url = new URL(`${appUrl}/${organizationId}/tasks/${entityId}`);
130+
131+
return {
132+
entityName: task.title || 'Task',
133+
entityRoutePath: 'tasks',
134+
commentUrl: url.toString(),
135+
};
136+
}
137+
138+
if (entityType === CommentEntityType.vendor) {
139+
const vendor = await db.vendor.findFirst({
140+
where: { id: entityId, organizationId },
141+
select: { name: true },
142+
});
143+
144+
if (!vendor) {
145+
return null;
146+
}
147+
148+
const url = new URL(`${appUrl}/${organizationId}/vendors/${entityId}`);
149+
150+
return {
151+
entityName: vendor.name || 'Vendor',
152+
entityRoutePath: 'vendors',
153+
commentUrl: url.toString(),
154+
};
155+
}
156+
157+
if (entityType === CommentEntityType.risk) {
158+
const risk = await db.risk.findFirst({
159+
where: { id: entityId, organizationId },
160+
select: { title: true },
161+
});
162+
163+
if (!risk) {
164+
return null;
165+
}
166+
167+
const url = new URL(`${appUrl}/${organizationId}/risk/${entityId}`);
168+
169+
return {
170+
entityName: risk.title || 'Risk',
171+
entityRoutePath: 'risk',
172+
commentUrl: url.toString(),
173+
};
174+
}
175+
176+
// CommentEntityType.policy
177+
const policy = await db.policy.findFirst({
178+
where: { id: entityId, organizationId },
179+
select: { name: true },
180+
});
181+
182+
if (!policy) {
183+
return null;
184+
}
185+
186+
const url = new URL(`${appUrl}/${organizationId}/policies/${entityId}`);
187+
188+
return {
189+
entityName: policy.name || 'Policy',
190+
entityRoutePath: 'policies',
191+
commentUrl: url.toString(),
192+
};
193+
}
194+
33195
@Injectable()
34196
export class CommentMentionNotifierService {
35197
private readonly logger = new Logger(CommentMentionNotifierService.name);
@@ -45,6 +207,7 @@ export class CommentMentionNotifierService {
45207
commentContent: string;
46208
entityType: CommentEntityType;
47209
entityId: string;
210+
contextUrl?: string;
48211
mentionedUserIds: string[];
49212
mentionedByUserId: string;
50213
}): Promise<void> {
@@ -54,6 +217,7 @@ export class CommentMentionNotifierService {
54217
commentContent,
55218
entityType,
56219
entityId,
220+
contextUrl,
57221
mentionedUserIds,
58222
mentionedByUserId,
59223
} = params;
@@ -62,14 +226,6 @@ export class CommentMentionNotifierService {
62226
return;
63227
}
64228

65-
// Only send notifications for task comments
66-
if (entityType !== CommentEntityType.task) {
67-
this.logger.log(
68-
`Skipping comment mention notifications: only task comments are supported (entityType: ${entityType})`,
69-
);
70-
return;
71-
}
72-
73229
try {
74230
// Get the user who mentioned others
75231
const mentionedByUser = await db.user.findUnique({
@@ -90,31 +246,27 @@ export class CommentMentionNotifierService {
90246
},
91247
});
92248

93-
// Get entity name for context (only for task comments)
94-
const taskItem = await db.taskItem.findUnique({
95-
where: { id: entityId },
96-
select: { title: true, entityType: true, entityId: true },
249+
const normalizedContextUrl = tryNormalizeContextUrl({
250+
organizationId,
251+
contextUrl,
97252
});
98-
const entityName = taskItem?.title || 'Unknown Task';
99-
// For task comments, we need to get the parent entity route
100-
let entityRoutePath = '';
101-
if (taskItem?.entityType === 'risk') {
102-
entityRoutePath = 'risk';
103-
} else if (taskItem?.entityType === 'vendor') {
104-
entityRoutePath = 'vendors';
253+
const fallback = await buildFallbackCommentContext({
254+
organizationId,
255+
entityType,
256+
entityId,
257+
});
258+
259+
// If entity not found in this organization, skip notifications for security
260+
if (!fallback) {
261+
this.logger.warn(
262+
`Skipping comment mention notifications: entity ${entityId} (${entityType}) not found in organization ${organizationId}`,
263+
);
264+
return;
105265
}
106266

107-
// Build comment URL (only for task comments)
108-
const appUrl =
109-
process.env.NEXT_PUBLIC_APP_URL ??
110-
process.env.BETTER_AUTH_URL ??
111-
'https://app.trycomp.ai';
112-
113-
// For task comments, link to the task item's parent entity
114-
const parentRoutePath = taskItem?.entityType === 'vendor' ? 'vendors' : 'risk';
115-
const commentUrl = taskItem
116-
? `${appUrl}/${organizationId}/${parentRoutePath}/${taskItem.entityId}?taskItemId=${entityId}#task-items`
117-
: '';
267+
const entityName = fallback.entityName;
268+
const entityRoutePath = fallback.entityRoutePath;
269+
const commentUrl = normalizedContextUrl ?? fallback.commentUrl;
118270

119271
const mentionedByName =
120272
mentionedByUser.name || mentionedByUser.email || 'Someone';

apps/api/src/comments/comments.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export class CommentsController {
163163
commentId,
164164
userId,
165165
updateCommentDto.content,
166+
updateCommentDto.contextUrl,
166167
);
167168
}
168169

apps/api/src/comments/comments.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export class CommentsService {
279279
commentContent: createCommentDto.content,
280280
entityType: createCommentDto.entityType,
281281
entityId: createCommentDto.entityId,
282+
contextUrl: createCommentDto.contextUrl,
282283
mentionedUserIds,
283284
mentionedByUserId: userId,
284285
});
@@ -315,6 +316,7 @@ export class CommentsService {
315316
commentId: string,
316317
userId: string,
317318
content: string,
319+
contextUrl?: string,
318320
): Promise<CommentResponseDto> {
319321
try {
320322
// Get comment and verify ownership/permissions
@@ -378,6 +380,7 @@ export class CommentsService {
378380
commentContent: content,
379381
entityType: existingComment.entityType,
380382
entityId: existingComment.entityId,
383+
contextUrl,
381384
mentionedUserIds: newlyMentionedUserIds,
382385
mentionedByUserId: userId,
383386
});

apps/api/src/comments/dto/create-comment.dto.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ export class CreateCommentDto {
3939
@IsEnum(CommentEntityType)
4040
entityType: CommentEntityType;
4141

42+
@ApiProperty({
43+
description:
44+
'Optional URL of the page where the comment was created, used for deep-linking in notifications',
45+
example:
46+
'https://app.trycomp.ai/org_abc123/vendors/vnd_abc123?taskItemId=tki_abc123#task-items',
47+
required: false,
48+
maxLength: 2048,
49+
})
50+
@IsOptional()
51+
@IsString()
52+
@MaxLength(2048)
53+
contextUrl?: string;
54+
4255
@ApiProperty({
4356
description: 'Optional attachments to include with the comment',
4457
type: [UploadAttachmentDto],

apps/api/src/comments/dto/update-comment.dto.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ export class UpdateCommentDto {
1212
@MaxLength(2000)
1313
content: string;
1414

15+
@ApiProperty({
16+
description:
17+
'Optional URL of the page where the comment was updated, used for deep-linking in notifications',
18+
example:
19+
'https://app.trycomp.ai/org_abc123/risk/rsk_abc123?taskItemId=tki_abc123#task-items',
20+
required: false,
21+
maxLength: 2048,
22+
})
23+
@IsOptional()
24+
@IsString()
25+
@MaxLength(2048)
26+
contextUrl?: string;
27+
1528
@ApiProperty({
1629
description:
1730
'User ID of the comment author (required for API key auth, ignored for JWT auth)',

apps/api/src/vendors/vendors.service.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ export class VendorsService {
8282
id,
8383
organizationId,
8484
},
85+
include: {
86+
assignee: {
87+
include: {
88+
user: {
89+
select: {
90+
id: true,
91+
name: true,
92+
email: true,
93+
image: true,
94+
},
95+
},
96+
},
97+
},
98+
},
8599
});
86100

87101
if (!vendor) {
@@ -90,8 +104,51 @@ export class VendorsService {
90104
);
91105
}
92106

107+
// Fetch risk assessment from GlobalVendors if vendor has a website
108+
const domain = extractDomain(vendor.website);
109+
let globalVendorData: {
110+
website: string;
111+
riskAssessmentData: Prisma.JsonValue;
112+
riskAssessmentVersion: string | null;
113+
riskAssessmentUpdatedAt: Date | null;
114+
} | null = null;
115+
116+
if (domain) {
117+
const duplicates = await db.globalVendors.findMany({
118+
where: {
119+
website: {
120+
contains: domain,
121+
},
122+
},
123+
select: {
124+
website: true,
125+
riskAssessmentData: true,
126+
riskAssessmentVersion: true,
127+
riskAssessmentUpdatedAt: true,
128+
},
129+
orderBy: [
130+
{ riskAssessmentUpdatedAt: 'desc' },
131+
{ createdAt: 'desc' },
132+
],
133+
});
134+
135+
// Prefer record WITH risk assessment data (most recent)
136+
globalVendorData =
137+
duplicates.find((gv) => gv.riskAssessmentData !== null) ??
138+
duplicates[0] ??
139+
null;
140+
}
141+
142+
// Merge GlobalVendors risk assessment data into response
143+
const vendorWithRiskAssessment = {
144+
...vendor,
145+
riskAssessmentData: globalVendorData?.riskAssessmentData ?? null,
146+
riskAssessmentVersion: globalVendorData?.riskAssessmentVersion ?? null,
147+
riskAssessmentUpdatedAt: globalVendorData?.riskAssessmentUpdatedAt ?? null,
148+
};
149+
93150
this.logger.log(`Retrieved vendor: ${vendor.name} (${id})`);
94-
return vendor;
151+
return vendorWithRiskAssessment;
95152
} catch (error) {
96153
if (error instanceof NotFoundException) {
97154
throw error;

0 commit comments

Comments
 (0)