@@ -30,6 +30,167 @@ 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+ 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 ( )
34195export 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' ;
0 commit comments