Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
211 changes: 181 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,167 @@ 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.
if (!url.pathname.includes(`/${organizationId}/`)) return null;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

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 +206,7 @@ export class CommentMentionNotifierService {
commentContent: string;
entityType: CommentEntityType;
entityId: string;
contextUrl?: string;
mentionedUserIds: string[];
mentionedByUserId: string;
}): Promise<void> {
Expand All @@ -54,6 +216,7 @@ export class CommentMentionNotifierService {
commentContent,
entityType,
entityId,
contextUrl,
mentionedUserIds,
mentionedByUserId,
} = params;
Expand All @@ -62,14 +225,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 +245,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
20 changes: 13 additions & 7 deletions apps/app/src/components/comments/CommentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,21 @@ export function CommentForm({ entityId, entityType }: CommentFormProps) {
const { createCommentWithFiles } = useCommentWithAttachments();
const { members } = useOrganizationMembers();

// Convert members to MentionUser format
// Convert members to MentionUser format - only show admin/owner users
const mentionMembers = useMemo(() => {
if (!members) return [];
return members.map((member) => ({
id: member.user.id,
name: member.user.name || member.user.email || 'Unknown',
email: member.user.email || '',
image: member.user.image,
}));
return members
.filter((member) => {
if (!member.role) return false;
const roles = member.role.split(',').map((r) => r.trim().toLowerCase());
return roles.includes('owner') || roles.includes('admin');
})
.map((member) => ({
id: member.user.id,
name: member.user.name || member.user.email || 'Unknown',
email: member.user.email || '',
image: member.user.image,
}));
}, [members]);

const triggerFileInput = () => {
Expand Down
20 changes: 13 additions & 7 deletions apps/app/src/components/comments/CommentItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,21 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) {
const { get: apiGet } = useApi();
const { members } = useOrganizationMembers();

// Convert members to MentionUser format
// Convert members to MentionUser format - only show admin/owner users
const mentionMembers = useMemo(() => {
if (!members) return [];
return members.map((member) => ({
id: member.user.id,
name: member.user.name || member.user.email || 'Unknown',
email: member.user.email || '',
image: member.user.image,
}));
return members
.filter((member) => {
if (!member.role) return false;
const roles = member.role.split(',').map((r) => r.trim().toLowerCase());
return roles.includes('owner') || roles.includes('admin');
})
.map((member) => ({
id: member.user.id,
name: member.user.name || member.user.email || 'Unknown',
email: member.user.email || '',
image: member.user.image,
}));
}, [members]);

// Parse comment content to JSONContent
Expand Down
Loading
Loading