From f943727e9d8bf92c0c001af4b2db44c72296e6f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:17:36 -0500 Subject: [PATCH 01/10] [dev] [tofikwest] tofik/vendor-risk-task-assignment (#1947) * feat: task assignment for vendor and records * refactor(auth): simplify role validation and update entity types * refactor(task): clean and fix bug * feat(task): add GetTaskItemStatsQueryDto for task item stats retrieval * chore: added focus mode for task, improved logic and cleaning up * feat(task): add task item attachment upload and activity logging * feat: add comments to task, notifications in email and in-appm clean code * feat: risk assesstment for vendors, fix some bugs * refactor(notifications): clean up NovuService fetch logic and error handling * feat(api): add INTERNAL_API_TOKEN to environment example * feat(env): add INTERNAL_API_TOKEN to environment configuration * chore(api): fix bugs * fix(api): update default framework ID from iso42001 to iso27001 * fix(api): correct entity route path for risk in comment notifier --------- Co-authored-by: Tofik Hasanov --- apps/api/.env.example | 2 + apps/api/src/app.module.ts | 2 + apps/api/src/app/s3.ts | 6 +- .../src/attachments/attachments.service.ts | 17 +- apps/api/src/auth/auth-context.decorator.ts | 4 +- apps/api/src/auth/auth.module.ts | 5 +- apps/api/src/auth/hybrid-auth.guard.ts | 16 + apps/api/src/auth/internal-token.guard.ts | 46 + apps/api/src/auth/role-validator.guard.ts | 68 + apps/api/src/auth/types.ts | 2 + .../comment-mention-notifier.service.ts | 226 ++++ apps/api/src/comments/comments.controller.ts | 6 +- apps/api/src/comments/comments.module.ts | 4 +- apps/api/src/comments/comments.service.ts | 109 +- .../src/email/templates/comment-mentioned.tsx | 152 +++ .../email/templates/task-item-assigned.tsx | 112 ++ .../email/templates/task-item-mentioned.tsx | 118 ++ apps/api/src/lib/fleet.service.ts | 9 +- apps/api/src/notifications/novu.service.ts | 62 + .../src/organization/organization.service.ts | 4 +- .../schemas/get-organization-primary-color.ts | 1 - apps/api/src/people/utils/member-queries.ts | 4 +- .../schemas/get-all-policies.responses.ts | 6 +- .../questionnaire/questionnaire.controller.ts | 5 +- .../questionnaire/questionnaire.service.ts | 35 +- .../dto/create-task-item.dto.ts | 52 + .../dto/get-task-item-query.dto.ts | 116 ++ .../dto/get-task-item-stats-query.dto.ts | 21 + .../dto/paginated-task-item-response.dto.ts | 33 + .../dto/task-item-response.dto.ts | 87 ++ .../dto/update-task-item.dto.ts | 47 + .../dto/upload-task-item-attachment.dto.ts | 62 + .../task-item-assignment-notifier.service.ts | 203 +++ .../task-item-audit.service.ts | 157 +++ .../task-item-mention-notifier.service.ts | 198 +++ .../task-management.controller.ts | 335 +++++ .../task-management/task-management.module.ts | 23 + .../task-management.service.ts | 754 +++++++++++ .../task-management/utils/extract-mentions.ts | 38 + .../task-management/utils/format-activity.ts | 28 + .../backfill-vendor-risk-assessment-tasks.ts | 113 ++ .../vendor/vendor-risk-assessment-task.ts | 158 +++ .../vendor/vendor-risk-assessment/assignee.ts | 47 + .../vendor-risk-assessment/constants.ts | 5 + .../vendor-risk-assessment/description.ts | 164 +++ .../vendor-risk-assessment/firecrawl.ts | 221 ++++ .../vendor-risk-assessment/frameworks.ts | 87 ++ .../vendor/vendor-risk-assessment/schema.ts | 32 + .../dto/trigger-vendor-risk-assessment.dto.ts | 55 + .../internal-vendor-automation.controller.ts | 49 + apps/api/src/vendors/vendors.controller.ts | 1 + apps/api/src/vendors/vendors.module.ts | 3 +- apps/api/src/vendors/vendors.service.ts | 87 +- apps/api/tsconfig.json | 4 +- apps/app/.env.example | 3 + apps/app/package.json | 1 + apps/app/src/actions/research-vendor.ts | 15 +- .../app/(app)/[orgId]/risk/[riskId]/page.tsx | 31 +- .../user/actions/update-email-preferences.ts | 2 + .../EmailNotificationPreferences.tsx | 32 + .../app/(app)/[orgId]/settings/user/page.tsx | 4 + .../tasks/components/CreateTaskSheet.tsx | 16 +- .../[orgId]/tasks/components/TaskList.tsx | 8 +- apps/app/src/app/(app)/[orgId]/tasks/page.tsx | 2 +- .../(overview)/components/VendorsTable.tsx | 4 +- .../(app)/[orgId]/vendors/(overview)/page.tsx | 2 +- .../(app)/[orgId]/vendors/[vendorId]/page.tsx | 60 +- .../vendors/actions/create-vendor-action.ts | 29 +- .../actions/search-global-vendors-action.ts | 4 +- .../vendors/backup-overview/layout.tsx | 2 +- .../vendors/components/create-vendor-form.tsx | 16 +- .../components/create-vendor-sheet.tsx | 12 +- .../onboarding/actions/complete-onboarding.ts | 24 +- .../hooks/usePostPaymentOnboarding.ts | 5 +- .../(app)/setup/hooks/useOnboardingForm.ts | 5 +- .../preferences/actions/update-preferences.ts | 2 + .../app/unsubscribe/preferences/client.tsx | 40 + .../src/app/unsubscribe/preferences/page.tsx | 4 + apps/app/src/components/SelectAssignee.tsx | 24 +- .../comments/CommentContentView.tsx | 155 +++ .../src/components/comments/CommentForm.tsx | 111 +- .../src/components/comments/CommentItem.tsx | 62 +- .../comments/CommentRichTextField.tsx | 139 +++ apps/app/src/components/main-menu.tsx | 2 +- .../components/task-items/StatusCircle.tsx | 195 +++ .../task-items/TaskItemActivityTimeline.tsx | 119 ++ .../task-items/TaskItemCreateDialog.tsx | 63 + .../task-items/TaskItemDescriptionView.tsx | 164 +++ .../TaskItemEditableDescription.tsx | 329 +++++ .../task-items/TaskItemEditableFields.tsx | 51 + .../task-items/TaskItemEditableTitle.tsx | 92 ++ .../task-items/TaskItemFocusSidebar.tsx | 209 ++++ .../task-items/TaskItemFocusView.tsx | 254 ++++ .../components/task-items/TaskItemForm.tsx | 27 + .../components/task-items/TaskItemItem.tsx | 736 +++++++++++ .../components/task-items/TaskItemList.tsx | 60 + .../task-items/TaskItemPagination.tsx | 75 ++ .../TaskItemScrollableDescription.tsx | 94 ++ .../src/components/task-items/TaskItems.tsx | 306 +++++ .../components/task-items/TaskItemsBody.tsx | 93 ++ .../task-items/TaskItemsContent.tsx | 127 ++ .../task-items/TaskItemsFilters.tsx | 140 +++ .../components/task-items/TaskItemsHeader.tsx | 118 ++ .../components/task-items/TaskItemsInline.tsx | 61 + .../task-items/TaskRichDescriptionField.tsx | 600 +++++++++ .../components/task-items/TaskSmartForm.tsx | 347 ++++++ .../hooks/use-task-item-activity.ts | 43 + .../hooks/use-task-item-attachment-upload.ts | 137 ++ .../components/task-items/task-item-utils.ts | 94 ++ apps/app/src/env.mjs | 2 + apps/app/src/hooks/use-task-items.ts | 592 +++++++++ .../onboard-organization-helpers.ts | 172 ++- apps/app/src/utils/filter-members-by-role.ts | 31 + bun.lock | 3 + .../migration.sql | 54 + .../migration.sql | 2 + packages/db/prisma/schema/attachments.prisma | 1 + packages/db/prisma/schema/auth.prisma | 3 + packages/db/prisma/schema/organization.prisma | 1 + packages/db/prisma/schema/task-item.prisma | 55 + packages/docs/openapi.json | 1100 +++++++++++++++-- packages/email/lib/check-unsubscribe.ts | 6 +- packages/ui/package.json | 12 + .../ui/src/components/editor/extensions.ts | 2 + .../extensions/file-attachment-view.tsx | 296 +++++ .../editor/extensions/file-attachment.tsx | 138 +++ .../components/editor/extensions/mention.tsx | 285 +++++ packages/ui/src/components/editor/index.tsx | 3 + .../editor/utils/validate-content.ts | 6 + packages/ui/src/editor.css | 4 +- 130 files changed, 12049 insertions(+), 270 deletions(-) create mode 100644 apps/api/src/auth/internal-token.guard.ts create mode 100644 apps/api/src/auth/role-validator.guard.ts create mode 100644 apps/api/src/comments/comment-mention-notifier.service.ts create mode 100644 apps/api/src/email/templates/comment-mentioned.tsx create mode 100644 apps/api/src/email/templates/task-item-assigned.tsx create mode 100644 apps/api/src/email/templates/task-item-mentioned.tsx create mode 100644 apps/api/src/notifications/novu.service.ts create mode 100644 apps/api/src/task-management/dto/create-task-item.dto.ts create mode 100644 apps/api/src/task-management/dto/get-task-item-query.dto.ts create mode 100644 apps/api/src/task-management/dto/get-task-item-stats-query.dto.ts create mode 100644 apps/api/src/task-management/dto/paginated-task-item-response.dto.ts create mode 100644 apps/api/src/task-management/dto/task-item-response.dto.ts create mode 100644 apps/api/src/task-management/dto/update-task-item.dto.ts create mode 100644 apps/api/src/task-management/dto/upload-task-item-attachment.dto.ts create mode 100644 apps/api/src/task-management/task-item-assignment-notifier.service.ts create mode 100644 apps/api/src/task-management/task-item-audit.service.ts create mode 100644 apps/api/src/task-management/task-item-mention-notifier.service.ts create mode 100644 apps/api/src/task-management/task-management.controller.ts create mode 100644 apps/api/src/task-management/task-management.module.ts create mode 100644 apps/api/src/task-management/task-management.service.ts create mode 100644 apps/api/src/task-management/utils/extract-mentions.ts create mode 100644 apps/api/src/task-management/utils/format-activity.ts create mode 100644 apps/api/src/trigger/vendor/backfill-vendor-risk-assessment-tasks.ts create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/assignee.ts create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/constants.ts create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl.ts create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/frameworks.ts create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/schema.ts create mode 100644 apps/api/src/vendors/dto/trigger-vendor-risk-assessment.dto.ts create mode 100644 apps/api/src/vendors/internal-vendor-automation.controller.ts create mode 100644 apps/app/src/components/comments/CommentContentView.tsx create mode 100644 apps/app/src/components/comments/CommentRichTextField.tsx create mode 100644 apps/app/src/components/task-items/StatusCircle.tsx create mode 100644 apps/app/src/components/task-items/TaskItemActivityTimeline.tsx create mode 100644 apps/app/src/components/task-items/TaskItemCreateDialog.tsx create mode 100644 apps/app/src/components/task-items/TaskItemDescriptionView.tsx create mode 100644 apps/app/src/components/task-items/TaskItemEditableDescription.tsx create mode 100644 apps/app/src/components/task-items/TaskItemEditableFields.tsx create mode 100644 apps/app/src/components/task-items/TaskItemEditableTitle.tsx create mode 100644 apps/app/src/components/task-items/TaskItemFocusSidebar.tsx create mode 100644 apps/app/src/components/task-items/TaskItemFocusView.tsx create mode 100644 apps/app/src/components/task-items/TaskItemForm.tsx create mode 100644 apps/app/src/components/task-items/TaskItemItem.tsx create mode 100644 apps/app/src/components/task-items/TaskItemList.tsx create mode 100644 apps/app/src/components/task-items/TaskItemPagination.tsx create mode 100644 apps/app/src/components/task-items/TaskItemScrollableDescription.tsx create mode 100644 apps/app/src/components/task-items/TaskItems.tsx create mode 100644 apps/app/src/components/task-items/TaskItemsBody.tsx create mode 100644 apps/app/src/components/task-items/TaskItemsContent.tsx create mode 100644 apps/app/src/components/task-items/TaskItemsFilters.tsx create mode 100644 apps/app/src/components/task-items/TaskItemsHeader.tsx create mode 100644 apps/app/src/components/task-items/TaskItemsInline.tsx create mode 100644 apps/app/src/components/task-items/TaskRichDescriptionField.tsx create mode 100644 apps/app/src/components/task-items/TaskSmartForm.tsx create mode 100644 apps/app/src/components/task-items/hooks/use-task-item-activity.ts create mode 100644 apps/app/src/components/task-items/hooks/use-task-item-attachment-upload.ts create mode 100644 apps/app/src/components/task-items/task-item-utils.ts create mode 100644 apps/app/src/hooks/use-task-items.ts create mode 100644 apps/app/src/utils/filter-members-by-role.ts create mode 100644 packages/db/prisma/migrations/20251218140802_add_task_item_table/migration.sql create mode 100644 packages/db/prisma/migrations/20251219200734_add_task_item_attachment_type/migration.sql create mode 100644 packages/db/prisma/schema/task-item.prisma create mode 100644 packages/ui/src/components/editor/extensions/file-attachment-view.tsx create mode 100644 packages/ui/src/components/editor/extensions/file-attachment.tsx create mode 100644 packages/ui/src/components/editor/extensions/mention.tsx 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/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..28a698309 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 = 60 * 1024 * 1024; // 60MB 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 + + . + +
+ +
+ +