@@ -30,6 +30,168 @@ function extractMentionedUserIds(content: string | null): string[] {
3030}
3131import { 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 ( )
34196export 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' ;
0 commit comments