Skip to content

Commit c4eba59

Browse files
[dev] [tofikwest] tofik/enable-to-use-mention-notification-for-any-page (#1974)
* feat(comments): add contextUrl for deep-linking in comment notifications * fix(comments): ensure entity belongs to organization in comment notifications * refactor(comments): filter mention users to show only admin and owner roles --------- Co-authored-by: Tofik Hasanov <annexcies@gmail.com>
1 parent 77b866f commit c4eba59

12 files changed

Lines changed: 332 additions & 79 deletions

File tree

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

Lines changed: 181 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,167 @@ 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+
if (!url.pathname.includes(`/${organizationId}/`)) return null;
74+
75+
return url.toString();
76+
} catch {
77+
return null;
78+
}
79+
}
80+
81+
async function buildFallbackCommentContext(params: {
82+
organizationId: string;
83+
entityType: CommentEntityType;
84+
entityId: string;
85+
}): Promise<{
86+
entityName: string;
87+
entityRoutePath: string;
88+
commentUrl: string;
89+
} | null> {
90+
const { organizationId, entityType, entityId } = params;
91+
const appUrl = getAppBaseUrl();
92+
93+
if (entityType === CommentEntityType.task) {
94+
// CommentEntityType.task can be:
95+
// - TaskItem id (preferred)
96+
// - Task id (legacy)
97+
// Use findFirst with organizationId to ensure entity belongs to correct org
98+
const taskItem = await db.taskItem.findFirst({
99+
where: { id: entityId, organizationId },
100+
select: { title: true, entityType: true, entityId: true },
101+
});
102+
103+
if (taskItem) {
104+
const parentRoutePath = taskItem.entityType === 'vendor' ? 'vendors' : 'risk';
105+
const url = new URL(
106+
`${appUrl}/${organizationId}/${parentRoutePath}/${taskItem.entityId}`,
107+
);
108+
url.searchParams.set('taskItemId', entityId);
109+
url.hash = 'task-items';
110+
111+
return {
112+
entityName: taskItem.title || 'Task',
113+
entityRoutePath: parentRoutePath,
114+
commentUrl: url.toString(),
115+
};
116+
}
117+
118+
const task = await db.task.findFirst({
119+
where: { id: entityId, organizationId },
120+
select: { title: true },
121+
});
122+
123+
if (!task) {
124+
// Entity not found in this organization - do not send notification
125+
return null;
126+
}
127+
128+
const url = new URL(`${appUrl}/${organizationId}/tasks/${entityId}`);
129+
130+
return {
131+
entityName: task.title || 'Task',
132+
entityRoutePath: 'tasks',
133+
commentUrl: url.toString(),
134+
};
135+
}
136+
137+
if (entityType === CommentEntityType.vendor) {
138+
const vendor = await db.vendor.findFirst({
139+
where: { id: entityId, organizationId },
140+
select: { name: true },
141+
});
142+
143+
if (!vendor) {
144+
return null;
145+
}
146+
147+
const url = new URL(`${appUrl}/${organizationId}/vendors/${entityId}`);
148+
149+
return {
150+
entityName: vendor.name || 'Vendor',
151+
entityRoutePath: 'vendors',
152+
commentUrl: url.toString(),
153+
};
154+
}
155+
156+
if (entityType === CommentEntityType.risk) {
157+
const risk = await db.risk.findFirst({
158+
where: { id: entityId, organizationId },
159+
select: { title: true },
160+
});
161+
162+
if (!risk) {
163+
return null;
164+
}
165+
166+
const url = new URL(`${appUrl}/${organizationId}/risk/${entityId}`);
167+
168+
return {
169+
entityName: risk.title || 'Risk',
170+
entityRoutePath: 'risk',
171+
commentUrl: url.toString(),
172+
};
173+
}
174+
175+
// CommentEntityType.policy
176+
const policy = await db.policy.findFirst({
177+
where: { id: entityId, organizationId },
178+
select: { name: true },
179+
});
180+
181+
if (!policy) {
182+
return null;
183+
}
184+
185+
const url = new URL(`${appUrl}/${organizationId}/policies/${entityId}`);
186+
187+
return {
188+
entityName: policy.name || 'Policy',
189+
entityRoutePath: 'policies',
190+
commentUrl: url.toString(),
191+
};
192+
}
193+
33194
@Injectable()
34195
export class CommentMentionNotifierService {
35196
private readonly logger = new Logger(CommentMentionNotifierService.name);
@@ -45,6 +206,7 @@ export class CommentMentionNotifierService {
45206
commentContent: string;
46207
entityType: CommentEntityType;
47208
entityId: string;
209+
contextUrl?: string;
48210
mentionedUserIds: string[];
49211
mentionedByUserId: string;
50212
}): Promise<void> {
@@ -54,6 +216,7 @@ export class CommentMentionNotifierService {
54216
commentContent,
55217
entityType,
56218
entityId,
219+
contextUrl,
57220
mentionedUserIds,
58221
mentionedByUserId,
59222
} = params;
@@ -62,14 +225,6 @@ export class CommentMentionNotifierService {
62225
return;
63226
}
64227

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-
73228
try {
74229
// Get the user who mentioned others
75230
const mentionedByUser = await db.user.findUnique({
@@ -90,31 +245,27 @@ export class CommentMentionNotifierService {
90245
},
91246
});
92247

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 },
248+
const normalizedContextUrl = tryNormalizeContextUrl({
249+
organizationId,
250+
contextUrl,
97251
});
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';
252+
const fallback = await buildFallbackCommentContext({
253+
organizationId,
254+
entityType,
255+
entityId,
256+
});
257+
258+
// If entity not found in this organization, skip notifications for security
259+
if (!fallback) {
260+
this.logger.warn(
261+
`Skipping comment mention notifications: entity ${entityId} (${entityType}) not found in organization ${organizationId}`,
262+
);
263+
return;
105264
}
106265

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-
: '';
266+
const entityName = fallback.entityName;
267+
const entityRoutePath = fallback.entityRoutePath;
268+
const commentUrl = normalizedContextUrl ?? fallback.commentUrl;
118269

119270
const mentionedByName =
120271
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/app/src/components/comments/CommentForm.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,21 @@ export function CommentForm({ entityId, entityType }: CommentFormProps) {
4141
const { createCommentWithFiles } = useCommentWithAttachments();
4242
const { members } = useOrganizationMembers();
4343

44-
// Convert members to MentionUser format
44+
// Convert members to MentionUser format - only show admin/owner users
4545
const mentionMembers = useMemo(() => {
4646
if (!members) return [];
47-
return members.map((member) => ({
48-
id: member.user.id,
49-
name: member.user.name || member.user.email || 'Unknown',
50-
email: member.user.email || '',
51-
image: member.user.image,
52-
}));
47+
return members
48+
.filter((member) => {
49+
if (!member.role) return false;
50+
const roles = member.role.split(',').map((r) => r.trim().toLowerCase());
51+
return roles.includes('owner') || roles.includes('admin');
52+
})
53+
.map((member) => ({
54+
id: member.user.id,
55+
name: member.user.name || member.user.email || 'Unknown',
56+
email: member.user.email || '',
57+
image: member.user.image,
58+
}));
5359
}, [members]);
5460

5561
const triggerFileInput = () => {

apps/app/src/components/comments/CommentItem.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,21 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) {
9999
const { get: apiGet } = useApi();
100100
const { members } = useOrganizationMembers();
101101

102-
// Convert members to MentionUser format
102+
// Convert members to MentionUser format - only show admin/owner users
103103
const mentionMembers = useMemo(() => {
104104
if (!members) return [];
105-
return members.map((member) => ({
106-
id: member.user.id,
107-
name: member.user.name || member.user.email || 'Unknown',
108-
email: member.user.email || '',
109-
image: member.user.image,
110-
}));
105+
return members
106+
.filter((member) => {
107+
if (!member.role) return false;
108+
const roles = member.role.split(',').map((r) => r.trim().toLowerCase());
109+
return roles.includes('owner') || roles.includes('admin');
110+
})
111+
.map((member) => ({
112+
id: member.user.id,
113+
name: member.user.name || member.user.email || 'Unknown',
114+
email: member.user.email || '',
115+
image: member.user.image,
116+
}));
111117
}, [members]);
112118

113119
// Parse comment content to JSONContent

0 commit comments

Comments
 (0)