diff --git a/apps/api/.env.example b/apps/api/.env.example index 62cbdc03d..a3288c9a2 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -12,6 +12,8 @@ APP_AWS_ORG_ASSETS_BUCKET= DATABASE_URL= +NOVU_API_KEY= +INTERNAL_API_TOKEN= # Upstash UPSTASH_REDIS_REST_URL= diff --git a/apps/api/Dockerfile.multistage b/apps/api/Dockerfile.multistage index 29e446d44..d4ae7f6b9 100644 --- a/apps/api/Dockerfile.multistage +++ b/apps/api/Dockerfile.multistage @@ -13,6 +13,7 @@ COPY packages/db/package.json ./packages/db/ COPY packages/utils/package.json ./packages/utils/ COPY packages/integration-platform/package.json ./packages/integration-platform/ COPY packages/tsconfig/package.json ./packages/tsconfig/ +COPY packages/email/package.json ./packages/email/ # Copy API package.json COPY apps/api/package.json ./apps/api/ @@ -32,6 +33,7 @@ COPY packages/db ./packages/db COPY packages/utils ./packages/utils COPY packages/integration-platform ./packages/integration-platform COPY packages/tsconfig ./packages/tsconfig +COPY packages/email ./packages/email # Copy API source COPY apps/api ./apps/api @@ -42,6 +44,7 @@ COPY --from=deps /app/node_modules ./node_modules # Build workspace packages RUN cd packages/db && bun run build && cd ../.. RUN cd packages/integration-platform && bun run build && cd ../.. +RUN cd packages/email && bun run build && cd ../.. # Generate Prisma client for API (copy schema and generate) RUN cd packages/db && node scripts/combine-schemas.js && cd ../.. @@ -75,6 +78,7 @@ COPY --from=builder /app/packages/db ./packages/db COPY --from=builder /app/packages/utils ./packages/utils COPY --from=builder /app/packages/integration-platform ./packages/integration-platform COPY --from=builder /app/packages/tsconfig ./packages/tsconfig +COPY --from=builder /app/packages/email ./packages/email # Copy production node_modules (includes symlinks to workspace packages above) COPY --from=builder /app/node_modules ./node_modules @@ -101,4 +105,3 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ # Start the application CMD ["node", "dist/src/main.js"] - diff --git a/apps/api/package.json b/apps/api/package.json index d74777453..36ed13573 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "@browserbasehq/sdk": "^2.6.0", "@browserbasehq/stagehand": "^3.0.5", "@comp/integration-platform": "workspace:*", + "@mendable/firecrawl-js": "^4.9.3", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", @@ -25,7 +26,8 @@ "@react-email/components": "^0.0.41", "@trigger.dev/build": "4.0.6", "@trigger.dev/sdk": "4.0.6", - "@trycompai/db": "1.3.19", + "@trycompai/db": "^1.3.20-canary.0", + "@trycompai/email": "workspace:*", "@upstash/vector": "^1.2.2", "adm-zip": "^0.5.16", "ai": "^5.0.60", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 0c8e50af9..4bdd378f3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -28,6 +28,7 @@ import { SOAModule } from './soa/soa.module'; import { IntegrationPlatformModule } from './integration-platform/integration-platform.module'; import { CloudSecurityModule } from './cloud-security/cloud-security.module'; import { BrowserbaseModule } from './browserbase/browserbase.module'; +import { TaskManagementModule } from './task-management/task-management.module'; @Module({ imports: [ @@ -68,6 +69,7 @@ import { BrowserbaseModule } from './browserbase/browserbase.module'; IntegrationPlatformModule, CloudSecurityModule, BrowserbaseModule, + TaskManagementModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/app/s3.ts b/apps/api/src/app/s3.ts index d003c5240..ea96b519e 100644 --- a/apps/api/src/app/s3.ts +++ b/apps/api/src/app/s3.ts @@ -1,4 +1,8 @@ -import { GetObjectCommand, S3Client, type GetObjectCommandOutput } from '@aws-sdk/client-s3'; +import { + GetObjectCommand, + S3Client, + type GetObjectCommandOutput, +} from '@aws-sdk/client-s3'; import { Logger } from '@nestjs/common'; import '../config/load-env'; diff --git a/apps/api/src/attachments/attachments.service.ts b/apps/api/src/attachments/attachments.service.ts index 76be8bb16..46561a2d7 100644 --- a/apps/api/src/attachments/attachments.service.ts +++ b/apps/api/src/attachments/attachments.service.ts @@ -19,7 +19,7 @@ import { UploadAttachmentDto } from './upload-attachment.dto'; export class AttachmentsService { private s3Client: S3Client; private bucketName: string; - private readonly MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB + private readonly MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; // 100MB private readonly SIGNED_URL_EXPIRY = 900; // 15 minutes constructor() { @@ -129,7 +129,20 @@ export class AttachmentsService { const fileId = randomBytes(16).toString('hex'); const sanitizedFileName = this.sanitizeFileName(uploadDto.fileName); const timestamp = Date.now(); - const s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`; + + // Special S3 path structure for task items: org_{orgId}/attachments/task-item/{entityType}/{entityId} + let s3Key: string; + if (entityType === 'task_item') { + // For task items, extract entityType and entityId from metadata + // Metadata should contain taskItemEntityType and taskItemEntityId + const taskItemEntityType = + uploadDto.description?.split('|')[0] || 'unknown'; + const taskItemEntityId = + uploadDto.description?.split('|')[1] || entityId; + s3Key = `${organizationId}/attachments/task-item/${taskItemEntityType}/${taskItemEntityId}/${timestamp}-${fileId}-${sanitizedFileName}`; + } else { + s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`; + } // Upload to S3 const putCommand = new PutObjectCommand({ diff --git a/apps/api/src/auth/auth-context.decorator.ts b/apps/api/src/auth/auth-context.decorator.ts index 618c712fc..294b4d5f8 100644 --- a/apps/api/src/auth/auth-context.decorator.ts +++ b/apps/api/src/auth/auth-context.decorator.ts @@ -9,7 +9,8 @@ export const AuthContext = createParamDecorator( (data: unknown, ctx: ExecutionContext): AuthContextType => { const request = ctx.switchToHttp().getRequest(); - const { organizationId, authType, isApiKey, userId, userEmail } = request; + const { organizationId, authType, isApiKey, userId, userEmail, userRoles } = + request; if (!organizationId || !authType) { throw new Error( @@ -23,6 +24,7 @@ export const AuthContext = createParamDecorator( isApiKey, userId, userEmail, + userRoles, }; }, ); diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 5f1d0e2e1..c687cadf4 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { ApiKeyGuard } from './api-key.guard'; import { ApiKeyService } from './api-key.service'; import { HybridAuthGuard } from './hybrid-auth.guard'; +import { InternalTokenGuard } from './internal-token.guard'; @Module({ - providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard], - exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard], + providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard], + exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard], }) export class AuthModule {} diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index 91441bc71..11655a070 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -70,6 +70,8 @@ export class HybridAuthGuard implements CanActivate { request.organizationId = organizationId; request.authType = 'api-key'; request.isApiKey = true; + // API keys are organization-scoped and are not tied to a specific user/member. + request.userRoles = null; return true; } @@ -171,9 +173,23 @@ export class HybridAuthGuard implements CanActivate { ); } + const member = await db.member.findFirst({ + where: { + userId, + organizationId: explicitOrgId, + deactivated: false, + }, + select: { + role: true, + }, + }); + + const userRoles = member?.role ? member.role.split(',') : null; + // Set request context for JWT auth request.userId = userId; request.userEmail = userEmail; + request.userRoles = userRoles; request.organizationId = explicitOrgId; request.authType = 'jwt'; request.isApiKey = false; diff --git a/apps/api/src/auth/internal-token.guard.ts b/apps/api/src/auth/internal-token.guard.ts new file mode 100644 index 000000000..d0e6ec5e7 --- /dev/null +++ b/apps/api/src/auth/internal-token.guard.ts @@ -0,0 +1,46 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; + +type RequestWithHeaders = { + headers: Record; +}; + +@Injectable() +export class InternalTokenGuard implements CanActivate { + private readonly logger = new Logger(InternalTokenGuard.name); + + canActivate(context: ExecutionContext): boolean { + const expectedToken = process.env.INTERNAL_API_TOKEN; + + // In production, we require the token to be configured. + if (!expectedToken) { + if (process.env.NODE_ENV === 'production') { + this.logger.error('INTERNAL_API_TOKEN is not configured in production'); + throw new UnauthorizedException('Internal access is not configured'); + } + + // In local/dev, allow requests if not configured to keep DX smooth. + this.logger.warn( + 'INTERNAL_API_TOKEN is not configured; allowing internal request in non-production', + ); + return true; + } + + const req = context.switchToHttp().getRequest(); + const headerValue = req.headers['x-internal-token']; + const token = Array.isArray(headerValue) ? headerValue[0] : headerValue; + + if (!token || token !== expectedToken) { + throw new UnauthorizedException('Invalid internal token'); + } + + return true; + } +} + + diff --git a/apps/api/src/auth/role-validator.guard.ts b/apps/api/src/auth/role-validator.guard.ts new file mode 100644 index 000000000..bd5fbe761 --- /dev/null +++ b/apps/api/src/auth/role-validator.guard.ts @@ -0,0 +1,68 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthenticatedRequest } from './types'; + +@Injectable() +export class RoleValidator implements CanActivate { + private readonly unauthenticatedErrorMessage: string; + private readonly noRolesSpecifiedErrorMessage: string; + private readonly accessDeniedErrorMessage: string; + private readonly allowedRoles: string[] | null; + + constructor(allowedRoles: string[] | null) { + this.allowedRoles = allowedRoles; + + this.unauthenticatedErrorMessage = + 'Role-based authorization requires user authentication (JWT token)'; + this.noRolesSpecifiedErrorMessage = 'No roles specified for authorization'; + this.accessDeniedErrorMessage = + 'Access denied. User does not have the required roles: {allowedRoles}, user has roles: {userRoles}'; + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + const { userRoles, userId, organizationId, authType, isApiKey } = request; + + if (!this.allowedRoles || this.allowedRoles.length === 0) { + throw new UnauthorizedException(this.noRolesSpecifiedErrorMessage); + } + + // API keys are organization-scoped and not tied to a specific user/member. + // They are allowed through role-protected endpoints. + if (isApiKey || authType === 'api-key') { + if (!organizationId) { + throw new UnauthorizedException( + 'Organization context required for API key authentication', + ); + } + + return true; + } + + // JWT requests must have user context + roles for role-based authorization + if (!userId || !organizationId || !userRoles || userRoles.length === 0) { + throw new UnauthorizedException(this.unauthenticatedErrorMessage); + } + + const hasRequiredRoles = this.allowedRoles.some((role) => + userRoles.includes(role), + ); + + if (!hasRequiredRoles) { + throw new UnauthorizedException( + this.accessDeniedErrorMessage + .replace('{allowedRoles}', this.allowedRoles.join(', ')) + .replace('{userRoles}', userRoles.join(', ')), + ); + } + + return true; + } +} + +export const RequireRoles = (...roles: string[]) => new RoleValidator(roles); diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts index 1c1a3a28b..0143395e4 100644 --- a/apps/api/src/auth/types.ts +++ b/apps/api/src/auth/types.ts @@ -6,6 +6,7 @@ export interface AuthenticatedRequest extends Request { isApiKey: boolean; userId?: string; userEmail?: string; + userRoles: string[] | null; } export interface AuthContext { @@ -14,4 +15,5 @@ export interface AuthContext { isApiKey: boolean; userId?: string; // Only available for JWT auth userEmail?: string; // Only available for JWT auth + userRoles: string[] | null; } diff --git a/apps/api/src/comments/comment-mention-notifier.service.ts b/apps/api/src/comments/comment-mention-notifier.service.ts new file mode 100644 index 000000000..447d61b62 --- /dev/null +++ b/apps/api/src/comments/comment-mention-notifier.service.ts @@ -0,0 +1,226 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { db } from '@db'; +import { isUserUnsubscribed } from '@trycompai/email'; +import { sendEmail } from '../email/resend'; +import { CommentMentionedEmail } from '../email/templates/comment-mentioned'; +import { NovuService } from '../notifications/novu.service'; +// Reuse the extract mentions utility +function extractMentionedUserIds(content: string | null): string[] { + if (!content) return []; + + try { + const parsed = typeof content === 'string' ? JSON.parse(content) : content; + if (!parsed || typeof parsed !== 'object') return []; + + const mentionedUserIds: string[] = []; + function traverse(node: any) { + if (!node || typeof node !== 'object') return; + if (node.type === 'mention' && node.attrs?.id) { + mentionedUserIds.push(node.attrs.id); + } + if (Array.isArray(node.content)) { + node.content.forEach(traverse); + } + } + traverse(parsed); + return [...new Set(mentionedUserIds)]; + } catch { + return []; + } +} +import { CommentEntityType } from '@db'; + +@Injectable() +export class CommentMentionNotifierService { + private readonly logger = new Logger(CommentMentionNotifierService.name); + + constructor(private readonly novuService: NovuService) {} + + /** + * Notify mentioned users in a comment + */ + async notifyMentionedUsers(params: { + organizationId: string; + commentId: string; + commentContent: string; + entityType: CommentEntityType; + entityId: string; + mentionedUserIds: string[]; + mentionedByUserId: string; + }): Promise { + const { + organizationId, + commentId, + commentContent, + entityType, + entityId, + mentionedUserIds, + mentionedByUserId, + } = params; + + if (!mentionedUserIds || mentionedUserIds.length === 0) { + 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({ + where: { id: mentionedByUserId }, + }); + + if (!mentionedByUser) { + this.logger.warn( + `Cannot send mention notifications: user ${mentionedByUserId} not found`, + ); + return; + } + + // Get all mentioned users + const mentionedUsers = await db.user.findMany({ + where: { + id: { in: mentionedUserIds }, + }, + }); + + // 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 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'; + } + + // 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 mentionedByName = + mentionedByUser.name || mentionedByUser.email || 'Someone'; + + this.logger.log( + `Sending comment mention notifications to ${mentionedUsers.length} users for comment ${commentId}`, + ); + + // Send email notification to each mentioned user + for (const user of mentionedUsers) { + // Don't notify the user who mentioned themselves + if (user.id === mentionedByUserId) { + continue; + } + + if (!user.email) { + this.logger.warn( + `Skipping mention notification: user ${user.id} has no email`, + ); + continue; + } + + // Check if user is unsubscribed from comment mention notifications + // Note: We'll use 'taskMentions' preference for now, or create a new 'commentMentions' preference + const isUnsubscribed = await isUserUnsubscribed(db, user.email, 'taskMentions'); + if (isUnsubscribed) { + this.logger.log( + `Skipping mention notification: user ${user.email} is unsubscribed from mentions`, + ); + continue; + } + + const userName = user.name || user.email || 'User'; + + // Send email notification via Resend + try { + const { id } = await sendEmail({ + to: user.email, + subject: `${mentionedByName} mentioned you in a comment`, + react: CommentMentionedEmail({ + toName: userName, + toEmail: user.email, + commentContent, + mentionedByName, + entityName, + entityRoutePath, + entityId, + organizationId, + commentUrl, + }), + system: true, + }); + + this.logger.log( + `Comment mention email sent to ${user.email} (ID: ${id})`, + ); + } catch (error) { + this.logger.error( + `Failed to send comment mention email to ${user.email}:`, + error instanceof Error ? error.message : 'Unknown error', + ); + // Continue with other users even if one fails + } + + // Send in-app notification via Novu + this.logger.log( + `[NOVU] Attempting to send in-app notification to ${user.id} (subscriber: ${user.id}-${organizationId}) for comment ${commentId}`, + ); + try { + await this.novuService.trigger({ + workflowId: 'comment-mentioned', + subscriberId: `${user.id}-${organizationId}`, + email: user.email, + payload: { + commentContent, + mentionedByName, + entityName, + entityRoutePath, + entityId, + organizationId, + commentUrl, + }, + }); + + this.logger.log( + `[NOVU] Comment mention in-app notification sent to ${user.id}`, + ); + } catch (error) { + this.logger.error( + `[NOVU] Failed to send comment mention in-app notification to ${user.id}:`, + error instanceof Error ? error.message : 'Unknown error', + ); + // Continue with other users even if one fails + } + } + + this.logger.log( + `Sent comment mention notifications for comment ${commentId} to ${mentionedUsers.length} users`, + ); + } catch (error) { + this.logger.error( + `Failed to send comment mention notifications for comment ${commentId}:`, + error instanceof Error ? error.message : 'Unknown error', + ); + // Don't throw - notification failures should not block comment operations + } + } +} + diff --git a/apps/api/src/comments/comments.controller.ts b/apps/api/src/comments/comments.controller.ts index 7d0899898..15949d70f 100644 --- a/apps/api/src/comments/comments.controller.ts +++ b/apps/api/src/comments/comments.controller.ts @@ -263,11 +263,7 @@ export class CommentsController { userId = authContext.userId; } - await this.commentsService.deleteComment( - organizationId, - commentId, - userId, - ); + await this.commentsService.deleteComment(organizationId, commentId, userId); return { success: true, diff --git a/apps/api/src/comments/comments.module.ts b/apps/api/src/comments/comments.module.ts index 47698a451..763eb7379 100644 --- a/apps/api/src/comments/comments.module.ts +++ b/apps/api/src/comments/comments.module.ts @@ -3,11 +3,13 @@ import { AttachmentsModule } from '../attachments/attachments.module'; import { AuthModule } from '../auth/auth.module'; import { CommentsController } from './comments.controller'; import { CommentsService } from './comments.service'; +import { CommentMentionNotifierService } from './comment-mention-notifier.service'; +import { NovuService } from '../notifications/novu.service'; @Module({ imports: [AuthModule, AttachmentsModule], // Import AuthModule for HybridAuthGuard dependencies controllers: [CommentsController], - providers: [CommentsService], + providers: [CommentsService, CommentMentionNotifierService, NovuService], exports: [CommentsService], }) export class CommentsModule {} diff --git a/apps/api/src/comments/comments.service.ts b/apps/api/src/comments/comments.service.ts index 864ce6774..5cb7e578e 100644 --- a/apps/api/src/comments/comments.service.ts +++ b/apps/api/src/comments/comments.service.ts @@ -3,6 +3,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, + Logger, } from '@nestjs/common'; import { db } from '@trycompai/db'; import { AttachmentsService } from '../attachments/attachments.service'; @@ -11,10 +12,41 @@ import { CommentResponseDto, } from './dto/comment-responses.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; +import { CommentMentionNotifierService } from './comment-mention-notifier.service'; + +// Reuse the extract mentions utility +function extractMentionedUserIds(content: string | null): string[] { + if (!content) return []; + + try { + const parsed = typeof content === 'string' ? JSON.parse(content) : content; + if (!parsed || typeof parsed !== 'object') return []; + + const mentionedUserIds: string[] = []; + function traverse(node: any) { + if (!node || typeof node !== 'object') return; + if (node.type === 'mention' && node.attrs?.id) { + mentionedUserIds.push(node.attrs.id); + } + if (Array.isArray(node.content)) { + node.content.forEach(traverse); + } + } + traverse(parsed); + return [...new Set(mentionedUserIds)]; + } catch { + return []; + } +} @Injectable() export class CommentsService { - constructor(private readonly attachmentsService: AttachmentsService) {} + private readonly logger = new Logger(CommentsService.name); + + constructor( + private readonly attachmentsService: AttachmentsService, + private readonly mentionNotifier: CommentMentionNotifierService, + ) {} /** * Validate that the target entity exists and belongs to the organization @@ -28,6 +60,19 @@ export class CommentsService { switch (entityType) { case CommentEntityType.task: { + // Backward compatible: + // - TaskItem detail view uses CommentEntityType.task with a TaskItem id + // - Legacy compliance "Task" pages also use CommentEntityType.task with a Task id + // + // Prefer TaskItem lookup first, then fall back to Task. + const taskItem = await db.taskItem.findFirst({ + where: { id: entityId, organizationId }, + }); + if (taskItem) { + entityExists = true; + break; + } + const task = await db.task.findFirst({ where: { id: entityId, organizationId }, }); @@ -48,6 +93,22 @@ export class CommentsService { where: { id: entityId, organizationId }, }); entityExists = !!vendor; + if (!entityExists) { + // Check if vendor exists in a different org for better error message + const vendorInOtherOrg = await db.vendor.findFirst({ + where: { id: entityId }, + select: { organizationId: true }, + }); + if (vendorInOtherOrg) { + this.logger.warn('Vendor exists but in different organization', { + entityId, + requestedOrgId: organizationId, + actualOrgId: vendorInOtherOrg.organizationId, + }); + } else { + this.logger.warn('Vendor not found', { entityId, organizationId }); + } + } break; } @@ -64,7 +125,9 @@ export class CommentsService { } if (!entityExists) { - throw new BadRequestException(`${entityType} not found or access denied`); + throw new BadRequestException( + `${entityType} with id ${entityId} not found in organization ${organizationId} or access denied`, + ); } } @@ -205,6 +268,23 @@ export class CommentsService { }; }); + // Notify mentioned users + if (createCommentDto.content && userId) { + const mentionedUserIds = extractMentionedUserIds(createCommentDto.content); + if (mentionedUserIds.length > 0) { + // Fire-and-forget: notification failures should not block comment creation + void this.mentionNotifier.notifyMentionedUsers({ + organizationId, + commentId: result.comment.id, + commentContent: createCommentDto.content, + entityType: createCommentDto.entityType, + entityId: createCommentDto.entityId, + mentionedUserIds, + mentionedByUserId: userId, + }); + } + } + return { id: result.comment.id, content: result.comment.content, @@ -279,6 +359,31 @@ export class CommentsService { AttachmentEntityType.comment, ); + // Notify only newly mentioned users on update (avoid re-notifying on typo edits) + if (content && userId) { + const previousMentioned = new Set( + extractMentionedUserIds(existingComment.content), + ); + const currentMentioned = extractMentionedUserIds(content); + + const newlyMentionedUserIds = currentMentioned.filter( + (id) => !previousMentioned.has(id), + ); + + if (newlyMentionedUserIds.length > 0) { + // Fire-and-forget: notification failures should not block comment update + void this.mentionNotifier.notifyMentionedUsers({ + organizationId, + commentId: updatedComment.id, + commentContent: content, + entityType: existingComment.entityType, + entityId: existingComment.entityId, + mentionedUserIds: newlyMentionedUserIds, + mentionedByUserId: userId, + }); + } + } + return { id: updatedComment.id, content: updatedComment.content, diff --git a/apps/api/src/email/templates/comment-mentioned.tsx b/apps/api/src/email/templates/comment-mentioned.tsx new file mode 100644 index 000000000..86e6bf4c4 --- /dev/null +++ b/apps/api/src/email/templates/comment-mentioned.tsx @@ -0,0 +1,152 @@ +import * as React from 'react'; +import { + Body, + Button, + Container, + Font, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from '@react-email/components'; +import { Footer } from '../components/footer'; +import { Logo } from '../components/logo'; +import { getUnsubscribeUrl } from '@trycompai/email'; + +interface Props { + toName: string; + toEmail: string; + commentContent: string; + mentionedByName: string; + entityName: string; + entityRoutePath: string; + entityId: string; + organizationId: string; + commentUrl: string; +} + +export const CommentMentionedEmail = ({ + toName, + toEmail, + commentContent, + mentionedByName, + entityName, + entityRoutePath, + entityId, + organizationId, + commentUrl, +}: Props) => { + const unsubscribeUrl = getUnsubscribeUrl(toEmail); + + // Extract plain text from TipTap JSON if needed + const getPlainText = (content: string): string => { + try { + const parsed = JSON.parse(content); + if (parsed && parsed.type === 'doc' && parsed.content) { + // Extract text from TipTap JSON + const extractText = (node: any): string => { + if (node.type === 'text') return node.text || ''; + if (node.type === 'mention') return `@${node.attrs?.label || node.attrs?.id || ''}`; + if (node.content && Array.isArray(node.content)) { + return node.content.map(extractText).join(''); + } + return ''; + }; + return parsed.content.map(extractText).join(' ').trim(); + } + } catch { + // Not JSON, return as-is + } + return content; + }; + + const plainTextContent = getPlainText(commentContent); + const previewText = plainTextContent.length > 100 + ? plainTextContent.substring(0, 100) + '...' + : plainTextContent; + + return ( + + + + + + + {mentionedByName} mentioned you in a comment + + + + + + You were mentioned in a comment + + + + Hello {toName}, + + + + {mentionedByName} mentioned you in a comment on{' '} + {entityName}. + + +
+ + "{previewText}" + +
+ +
+ +
+ + + or copy and paste this URL into your browser:{' '} + + {commentUrl} + + + +
+ + Don't want to receive comment mention notifications?{' '} + + Manage your email preferences + + . + +
+ +
+ +