Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 182 additions & 30 deletions apps/api/src/comments/comment-mention-notifier.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,168 @@ function extractMentionedUserIds(content: string | null): string[] {
}
import { CommentEntityType } from '@db';

function getAppBaseUrl(): string {
return (
process.env.NEXT_PUBLIC_APP_URL ??
process.env.BETTER_AUTH_URL ??
'https://app.trycomp.ai'
);
}

function getAllowedOrigins(): string[] {
const candidates = [
process.env.NEXT_PUBLIC_APP_URL,
process.env.BETTER_AUTH_URL,
'https://app.trycomp.ai',
].filter(Boolean) as string[];

const origins = new Set<string>();
for (const candidate of candidates) {
try {
origins.add(new URL(candidate).origin);
} catch {
// ignore invalid env values
}
}

return [...origins];
}

function tryNormalizeContextUrl(params: {
organizationId: string;
contextUrl?: string;
}): string | null {
const { organizationId, contextUrl } = params;
if (!contextUrl) return null;

try {
const url = new URL(contextUrl);
const allowedOrigins = new Set(getAllowedOrigins());
if (!allowedOrigins.has(url.origin)) return null;

// Ensure the URL is for the same org so we don't accidentally deep-link elsewhere.
// Use startsWith to prevent path traversal attacks (e.g., /attacker_org/victim_org/)
if (!url.pathname.startsWith(`/${organizationId}/`)) return null;

return url.toString();
} catch {
return null;
}
}

async function buildFallbackCommentContext(params: {
organizationId: string;
entityType: CommentEntityType;
entityId: string;
}): Promise<{
entityName: string;
entityRoutePath: string;
commentUrl: string;
} | null> {
const { organizationId, entityType, entityId } = params;
const appUrl = getAppBaseUrl();

if (entityType === CommentEntityType.task) {
// CommentEntityType.task can be:
// - TaskItem id (preferred)
// - Task id (legacy)
// Use findFirst with organizationId to ensure entity belongs to correct org
const taskItem = await db.taskItem.findFirst({
where: { id: entityId, organizationId },
select: { title: true, entityType: true, entityId: true },
});

if (taskItem) {
const parentRoutePath = taskItem.entityType === 'vendor' ? 'vendors' : 'risk';
const url = new URL(
`${appUrl}/${organizationId}/${parentRoutePath}/${taskItem.entityId}`,
);
url.searchParams.set('taskItemId', entityId);
url.hash = 'task-items';

return {
entityName: taskItem.title || 'Task',
entityRoutePath: parentRoutePath,
commentUrl: url.toString(),
};
}

const task = await db.task.findFirst({
where: { id: entityId, organizationId },
select: { title: true },
});

if (!task) {
// Entity not found in this organization - do not send notification
return null;
}

const url = new URL(`${appUrl}/${organizationId}/tasks/${entityId}`);

return {
entityName: task.title || 'Task',
entityRoutePath: 'tasks',
commentUrl: url.toString(),
};
}

if (entityType === CommentEntityType.vendor) {
const vendor = await db.vendor.findFirst({
where: { id: entityId, organizationId },
select: { name: true },
});

if (!vendor) {
return null;
}

const url = new URL(`${appUrl}/${organizationId}/vendors/${entityId}`);

return {
entityName: vendor.name || 'Vendor',
entityRoutePath: 'vendors',
commentUrl: url.toString(),
};
}

if (entityType === CommentEntityType.risk) {
const risk = await db.risk.findFirst({
where: { id: entityId, organizationId },
select: { title: true },
});

if (!risk) {
return null;
}

const url = new URL(`${appUrl}/${organizationId}/risk/${entityId}`);

return {
entityName: risk.title || 'Risk',
entityRoutePath: 'risk',
commentUrl: url.toString(),
};
}

// CommentEntityType.policy
const policy = await db.policy.findFirst({
where: { id: entityId, organizationId },
select: { name: true },
});

if (!policy) {
return null;
}

const url = new URL(`${appUrl}/${organizationId}/policies/${entityId}`);

return {
entityName: policy.name || 'Policy',
entityRoutePath: 'policies',
commentUrl: url.toString(),
};
}

@Injectable()
export class CommentMentionNotifierService {
private readonly logger = new Logger(CommentMentionNotifierService.name);
Expand All @@ -45,6 +207,7 @@ export class CommentMentionNotifierService {
commentContent: string;
entityType: CommentEntityType;
entityId: string;
contextUrl?: string;
mentionedUserIds: string[];
mentionedByUserId: string;
}): Promise<void> {
Expand All @@ -54,6 +217,7 @@ export class CommentMentionNotifierService {
commentContent,
entityType,
entityId,
contextUrl,
mentionedUserIds,
mentionedByUserId,
} = params;
Expand All @@ -62,14 +226,6 @@ export class CommentMentionNotifierService {
return;
}

// Only send notifications for task comments
if (entityType !== CommentEntityType.task) {
this.logger.log(
`Skipping comment mention notifications: only task comments are supported (entityType: ${entityType})`,
);
return;
}

try {
// Get the user who mentioned others
const mentionedByUser = await db.user.findUnique({
Expand All @@ -90,31 +246,27 @@ export class CommentMentionNotifierService {
},
});

// Get entity name for context (only for task comments)
const taskItem = await db.taskItem.findUnique({
where: { id: entityId },
select: { title: true, entityType: true, entityId: true },
const normalizedContextUrl = tryNormalizeContextUrl({
organizationId,
contextUrl,
});
const entityName = taskItem?.title || 'Unknown Task';
// For task comments, we need to get the parent entity route
let entityRoutePath = '';
if (taskItem?.entityType === 'risk') {
entityRoutePath = 'risk';
} else if (taskItem?.entityType === 'vendor') {
entityRoutePath = 'vendors';
const fallback = await buildFallbackCommentContext({
organizationId,
entityType,
entityId,
});

// If entity not found in this organization, skip notifications for security
if (!fallback) {
this.logger.warn(
`Skipping comment mention notifications: entity ${entityId} (${entityType}) not found in organization ${organizationId}`,
);
return;
}

// Build comment URL (only for task comments)
const appUrl =
process.env.NEXT_PUBLIC_APP_URL ??
process.env.BETTER_AUTH_URL ??
'https://app.trycomp.ai';

// For task comments, link to the task item's parent entity
const parentRoutePath = taskItem?.entityType === 'vendor' ? 'vendors' : 'risk';
const commentUrl = taskItem
? `${appUrl}/${organizationId}/${parentRoutePath}/${taskItem.entityId}?taskItemId=${entityId}#task-items`
: '';
const entityName = fallback.entityName;
const entityRoutePath = fallback.entityRoutePath;
const commentUrl = normalizedContextUrl ?? fallback.commentUrl;

const mentionedByName =
mentionedByUser.name || mentionedByUser.email || 'Someone';
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/comments/comments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export class CommentsController {
commentId,
userId,
updateCommentDto.content,
updateCommentDto.contextUrl,
);
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/comments/comments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export class CommentsService {
commentContent: createCommentDto.content,
entityType: createCommentDto.entityType,
entityId: createCommentDto.entityId,
contextUrl: createCommentDto.contextUrl,
mentionedUserIds,
mentionedByUserId: userId,
});
Expand Down Expand Up @@ -315,6 +316,7 @@ export class CommentsService {
commentId: string,
userId: string,
content: string,
contextUrl?: string,
): Promise<CommentResponseDto> {
try {
// Get comment and verify ownership/permissions
Expand Down Expand Up @@ -378,6 +380,7 @@ export class CommentsService {
commentContent: content,
entityType: existingComment.entityType,
entityId: existingComment.entityId,
contextUrl,
mentionedUserIds: newlyMentionedUserIds,
mentionedByUserId: userId,
});
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/comments/dto/create-comment.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ export class CreateCommentDto {
@IsEnum(CommentEntityType)
entityType: CommentEntityType;

@ApiProperty({
description:
'Optional URL of the page where the comment was created, used for deep-linking in notifications',
example:
'https://app.trycomp.ai/org_abc123/vendors/vnd_abc123?taskItemId=tki_abc123#task-items',
required: false,
maxLength: 2048,
})
@IsOptional()
@IsString()
@MaxLength(2048)
contextUrl?: string;

@ApiProperty({
description: 'Optional attachments to include with the comment',
type: [UploadAttachmentDto],
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/comments/dto/update-comment.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ export class UpdateCommentDto {
@MaxLength(2000)
content: string;

@ApiProperty({
description:
'Optional URL of the page where the comment was updated, used for deep-linking in notifications',
example:
'https://app.trycomp.ai/org_abc123/risk/rsk_abc123?taskItemId=tki_abc123#task-items',
required: false,
maxLength: 2048,
})
@IsOptional()
@IsString()
@MaxLength(2048)
contextUrl?: string;

@ApiProperty({
description:
'User ID of the comment author (required for API key auth, ignored for JWT auth)',
Expand Down
59 changes: 58 additions & 1 deletion apps/api/src/vendors/vendors.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ export class VendorsService {
id,
organizationId,
},
include: {
assignee: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
});

if (!vendor) {
Expand All @@ -90,8 +104,51 @@ export class VendorsService {
);
}

// 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,
},
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Substring domain match may return wrong vendor data

The findById method uses a substring contains filter to match vendor domains against GlobalVendors records. Since extractDomain returns just the hostname (e.g., "example.com"), the query website: { contains: domain } can match unrelated domains where the target is a substring. For example, searching for "comp.ai" would incorrectly match "notcomp.ai" or "encomp.ai", potentially displaying the wrong vendor's risk assessment data to users.

Fix in Cursor Fix in Web

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 vendor;
return vendorWithRiskAssessment;
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
Expand Down
Loading
Loading