From e179c5823328b29c2ee52cd49c53f2ef690469fb Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 8 Jul 2026 19:43:26 -0400 Subject: [PATCH 1/6] feat(org): treat platform admins as members in internal organizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Organization.isInternal` so platform-operated organizations can run compliance with their platform-admin staff as real participants — assignable, counted toward compliance progress, and included in notifications and device rollups. Behavior is unchanged for every other organization (flag defaults to false; admin-only toggle on the org admin screen). Centralizes the rule behind a single `isOrgParticipant` predicate (`@trycompai/auth/participation`, mirrored for the Trigger.dev bundle), replacing the scattered inline `user.role === 'admin'` participation checks across assignment/approval, compliance counting, notifications, reminders, and device queries. Platform-admin access guards are intentionally untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UWG1YQ8KsjefFAvRV7pdtb --- .../admin-organizations.service.ts | 1 + .../dto/update-admin-organization.dto.ts | 8 ++ .../comment-mention-notifier.service.ts | 10 +-- apps/api/src/people/people-fleet.helper.ts | 7 +- apps/api/src/policies/policies.service.ts | 57 ++++++------ apps/api/src/risks/risks.service.ts | 6 +- apps/api/src/soa/soa.service.spec.ts | 12 ++- apps/api/src/soa/soa.service.ts | 9 +- .../task-item-assignment-notifier.service.ts | 11 ++- .../task-item-mention-notifier.service.ts | 10 +-- .../task-management.service.ts | 23 ++++- apps/api/src/tasks/task-notifier.service.ts | 13 ++- apps/api/src/tasks/tasks.service.ts | 24 +++-- .../run-browser-automation.ts | 16 ++-- apps/api/src/utils/compliance-filters.ts | 9 +- apps/api/src/utils/org-participation.spec.ts | 73 +++++++++++++++ apps/api/src/utils/org-participation.ts | 54 ++++++++++++ apps/api/src/vendors/vendors.service.ts | 6 +- .../accept-requested-policy-changes.ts | 4 +- .../[adminOrgId]/components/AdminOrgTabs.tsx | 1 + .../components/OrganizationDetail.tsx | 88 ++++++++++++------- apps/app/src/app/(app)/[orgId]/layout.tsx | 66 ++++++-------- .../src/app/api/people/agent-devices/route.ts | 13 ++- .../src/app/api/people/fleet-hosts/route.ts | 87 +++++++++--------- apps/app/src/components/SelectAssignee.tsx | 19 ++-- .../src/components/org-internal-context.tsx | 28 ++++++ apps/app/src/lib/compliance.ts | 19 ++-- .../src/lib/org-participation-rule.test.ts | 28 ++++++ apps/app/src/lib/org-participation-rule.ts | 23 +++++ apps/app/src/lib/org-participation.ts | 20 +++++ .../onboarding/generate-vendor-mitigation.ts | 21 +++-- .../policy-acknowledgment-digest-helpers.ts | 11 +-- .../task/policy-acknowledgment-digest.ts | 20 ++--- .../src/trigger/tasks/task/policy-schedule.ts | 49 +++++++---- .../src/trigger/tasks/task/task-schedule.ts | 56 ++++++++---- .../tasks/task/weekly-task-reminder.ts | 27 ++++-- packages/auth/package.json | 4 + packages/auth/src/index.ts | 31 ++++--- packages/auth/src/participation.ts | 55 ++++++++++++ .../migration.sql | 6 ++ packages/db/prisma/schema/organization.prisma | 4 + 41 files changed, 732 insertions(+), 297 deletions(-) create mode 100644 apps/api/src/utils/org-participation.spec.ts create mode 100644 apps/api/src/utils/org-participation.ts create mode 100644 apps/app/src/components/org-internal-context.tsx create mode 100644 apps/app/src/lib/org-participation-rule.test.ts create mode 100644 apps/app/src/lib/org-participation-rule.ts create mode 100644 apps/app/src/lib/org-participation.ts create mode 100644 packages/auth/src/participation.ts create mode 100644 packages/db/prisma/migrations/20260708120000_add_organization_is_internal/migration.sql diff --git a/apps/api/src/admin-organizations/admin-organizations.service.ts b/apps/api/src/admin-organizations/admin-organizations.service.ts index a1a5c33f5..fc4ca96c5 100644 --- a/apps/api/src/admin-organizations/admin-organizations.service.ts +++ b/apps/api/src/admin-organizations/admin-organizations.service.ts @@ -240,6 +240,7 @@ export class AdminOrganizationsService { onboardingCompleted: true, website: true, backgroundCheckStepEnabled: true, + isInternal: true, members: { where: { isActive: true, deactivated: false }, select: { diff --git a/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts b/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts index 2026fee16..beaaa75e5 100644 --- a/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts +++ b/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts @@ -17,4 +17,12 @@ export class UpdateAdminOrganizationDto { @IsOptional() @IsBoolean() backgroundCheckStepEnabled?: boolean; + + @ApiPropertyOptional({ + description: + "When true, the organization is platform-operated (e.g. Comp AI's own org). Platform admins are then treated as real participants — assignable, counted in compliance, and notified. Leave false for all customer orgs.", + }) + @IsOptional() + @IsBoolean() + isInternal?: boolean; } diff --git a/apps/api/src/comments/comment-mention-notifier.service.ts b/apps/api/src/comments/comment-mention-notifier.service.ts index f15b641ab..69d6a4792 100644 --- a/apps/api/src/comments/comment-mention-notifier.service.ts +++ b/apps/api/src/comments/comment-mention-notifier.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { db } from '@db'; +import { orgParticipantMemberWhere } from '../utils/org-participation'; import { isUserUnsubscribed } from '@trycompai/email'; import { triggerEmail } from '../email/trigger-email'; import { CommentMentionedEmail } from '../email/templates/comment-mentioned'; @@ -261,16 +262,15 @@ export class CommentMentionNotifierService { return; } - // Get mentioned users: exclude platform admins unless they are an owner of this org + // Get mentioned users: exclude platform admins unless they are an owner of + // this org (or the org is internal, where platform admins are real members) + const participantWhere = await orgParticipantMemberWhere(organizationId); const mentionedMembers = await db.member.findMany({ where: { organizationId, deactivated: false, user: { id: { in: mentionedUserIds } }, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + ...participantWhere, }, include: { user: true }, }); diff --git a/apps/api/src/people/people-fleet.helper.ts b/apps/api/src/people/people-fleet.helper.ts index 784d26cc8..be4ed40c9 100644 --- a/apps/api/src/people/people-fleet.helper.ts +++ b/apps/api/src/people/people-fleet.helper.ts @@ -1,5 +1,6 @@ import { Logger } from '@nestjs/common'; import { db } from '@db'; +import { orgParticipantMemberWhere } from '../utils/org-participation'; import { FleetService } from '../lib/fleet.service'; const MDM_POLICY_ID = -9999; @@ -115,14 +116,12 @@ export async function getAllEmployeeDevices( organizationId: string, ) { try { + const participantWhere = await orgParticipantMemberWhere(organizationId); const employees = await db.member.findMany({ where: { organizationId, deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + ...participantWhere, }, include: { user: true }, }); diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index f420dc3ce..1c264efae 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -5,14 +5,12 @@ import { NotFoundException, } from '@nestjs/common'; import { db, Frequency, PolicyStatus, Prisma } from '@db'; -import { - HeadObjectCommand, - PutObjectCommand, -} from '@aws-sdk/client-s3'; +import { HeadObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; import { AttachmentsService } from '../attachments/attachments.service'; import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service'; import { filterComplianceMembers } from '../utils/compliance-filters'; +import { isMemberOrgParticipant } from '../utils/org-participation'; import { BUCKET_NAME, getSignedUrl, s3Client } from '../app/s3'; import type { CreatePolicyDto } from './dto/create-policy.dto'; import type { UpdatePolicyDto } from './dto/update-policy.dto'; @@ -99,9 +97,7 @@ export class PoliciesService { name: true, description: true, status: true, - ...(excludeContent - ? {} - : { content: true, draftContent: true }), + ...(excludeContent ? {} : { content: true, draftContent: true }), frequency: true, department: true, isRequiredToSign: true, @@ -215,7 +211,10 @@ export class PoliciesService { organizationId, timelinesService: this.timelinesService, }).catch((err) => { - this.logger.warn('timeline auto-complete check failed after publish-all', err); + this.logger.warn( + 'timeline auto-complete check failed after publish-all', + err, + ); }); return { @@ -301,7 +300,10 @@ export class PoliciesService { where: { id: createData.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (assignee?.user.role === 'admin') { + if ( + assignee && + !(await isMemberOrgParticipant(assignee.user.role, organizationId)) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); @@ -757,9 +759,10 @@ export class PoliciesService { sourceVersion = requestedVersion; } - const contentForVersion = (sourceVersion - ? (sourceVersion.content as Prisma.InputJsonValue[]) - : (policy.content as Prisma.InputJsonValue[])) ?? []; + const contentForVersion = + (sourceVersion + ? (sourceVersion.content as Prisma.InputJsonValue[]) + : (policy.content as Prisma.InputJsonValue[])) ?? []; const sourcePdfUrl = sourceVersion?.pdfUrl ?? policy.pdfUrl; // S3 copy is done AFTER the transaction to prevent orphaned files on retry @@ -1116,8 +1119,8 @@ export class PoliciesService { organizationId, timelinesService: this.timelinesService, }).catch((err) => { - this.logger.warn('timeline auto-complete check failed', err); - }); + this.logger.warn('timeline auto-complete check failed', err); + }); return result; } catch (error) { @@ -1180,8 +1183,8 @@ export class PoliciesService { organizationId, timelinesService: this.timelinesService, }).catch((err) => { - this.logger.warn('timeline auto-complete check failed', err); - }); + this.logger.warn('timeline auto-complete check failed', err); + }); return { versionId: version.id, @@ -1236,12 +1239,12 @@ export class PoliciesService { ); } - // Cannot assign a platform admin as approver + // Cannot assign a platform admin as approver (unless this is an internal org) const approverUser = await db.user.findUnique({ where: { id: approver.userId }, select: { role: true }, }); - if (approverUser?.role === 'admin') { + if (!(await isMemberOrgParticipant(approverUser?.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as approver', ); @@ -1340,8 +1343,8 @@ export class PoliciesService { organizationId, timelinesService: this.timelinesService, }).catch((err) => { - this.logger.warn('timeline auto-complete check failed', err); - }); + this.logger.warn('timeline auto-complete check failed', err); + }); // Publishing cleared signedBy[] above, so everyone with the compliance // obligation must (re-)acknowledge the new version. Surface that audience so @@ -1491,10 +1494,7 @@ export class PoliciesService { /** * Download all published policies as a single PDF bundle (no watermark) */ - async downloadAllPoliciesPdf( - organizationId: string, - policyIds?: string[], - ) { + async downloadAllPoliciesPdf(organizationId: string, policyIds?: string[]) { // Get organization info const organization = await db.organization.findUnique({ where: { id: organizationId }, @@ -1511,9 +1511,7 @@ export class PoliciesService { organizationId, isArchived: false, archivedAt: null, - ...(policyIds && policyIds.length > 0 - ? { id: { in: policyIds } } - : {}), + ...(policyIds && policyIds.length > 0 ? { id: { in: policyIds } } : {}), }, select: { id: true, @@ -1831,10 +1829,7 @@ export class PoliciesService { select: { id: true, version: true }, }); if (!version) throw new NotFoundException('Version not found'); - if ( - version.id === policy.currentVersionId && - policy.status !== 'draft' - ) { + if (version.id === policy.currentVersionId && policy.status !== 'draft') { throw new BadRequestException( 'Cannot upload PDF to the published version', ); diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts index e4d29cff9..5c5fe3c9b 100644 --- a/apps/api/src/risks/risks.service.ts +++ b/apps/api/src/risks/risks.service.ts @@ -5,6 +5,7 @@ import { Logger, } from '@nestjs/common'; import { db, Prisma } from '@db'; +import { isMemberOrgParticipant } from '../utils/org-participation'; import { CreateRiskDto } from './dto/create-risk.dto'; import { GetRisksQueryDto } from './dto/get-risks-query.dto'; import { UpdateRiskDto } from './dto/update-risk.dto'; @@ -39,7 +40,10 @@ export class RisksService { where: { id: assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (member?.user.role === 'admin') { + if ( + member && + !(await isMemberOrgParticipant(member.user.role, organizationId)) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); diff --git a/apps/api/src/soa/soa.service.spec.ts b/apps/api/src/soa/soa.service.spec.ts index 8a9b69ff3..ae0a199ad 100644 --- a/apps/api/src/soa/soa.service.spec.ts +++ b/apps/api/src/soa/soa.service.spec.ts @@ -26,6 +26,7 @@ jest.mock('@db', () => ({ }, member: { findFirst: jest.fn() }, user: { findUnique: jest.fn() }, + organization: { findUnique: jest.fn() }, sOAAnswer: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() }, }, })); @@ -366,9 +367,10 @@ describe('SOAService', () => { status: 'completed', }), }); - expect((mockDb.sOADocument.update as jest.Mock).mock.calls[0][0].data.declinedAt).toBeInstanceOf( - Date, - ); + expect( + (mockDb.sOADocument.update as jest.Mock).mock.calls[0][0].data + .declinedAt, + ).toBeInstanceOf(Date); }); }); @@ -480,7 +482,9 @@ describe('SOAService', () => { it('throws NotFoundException when document not found', async () => { (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null); - await expect(service.exportDocument(dto)).rejects.toThrow(NotFoundException); + await expect(service.exportDocument(dto)).rejects.toThrow( + NotFoundException, + ); }); it('maps document data and delegates to generateSOAExportFile', async () => { diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index d0c4c1f83..016e433b9 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -7,6 +7,7 @@ import { InternalServerErrorException, } from '@nestjs/common'; import { db } from '@db'; +import { isMemberOrgParticipant } from '../utils/org-participation'; import { SaveSOAAnswerDto } from './dto/save-soa-answer.dto'; import { CreateSOADocumentDto } from './dto/create-soa-document.dto'; import { EnsureSOASetupDto } from './dto/ensure-soa-setup.dto'; @@ -447,7 +448,9 @@ export class SOAService { where: { id: approverMember.userId }, select: { role: true }, }); - if (approverUser?.role === 'admin') { + if ( + !(await isMemberOrgParticipant(approverUser?.role, dto.organizationId)) + ) { throw new BadRequestException( 'Cannot assign a platform admin as approver', ); @@ -554,9 +557,7 @@ export class SOAService { declinedAt: (document as { declinedAt?: Date | null }).declinedAt ?? null, status: document.status, approverName: - document.approver?.user?.name || - document.approver?.user?.email || - null, + document.approver?.user?.name || document.approver?.user?.email || null, }; return generateSOAExportFile( diff --git a/apps/api/src/task-management/task-item-assignment-notifier.service.ts b/apps/api/src/task-management/task-item-assignment-notifier.service.ts index 8abc54e0b..ac8cb04a5 100644 --- a/apps/api/src/task-management/task-item-assignment-notifier.service.ts +++ b/apps/api/src/task-management/task-item-assignment-notifier.service.ts @@ -1,4 +1,5 @@ import { db } from '@db'; +import { isOrgParticipant } from '@trycompai/auth/participation'; import { Injectable, Logger } from '@nestjs/common'; import { isUserUnsubscribed } from '@trycompai/email'; import { triggerEmail } from '../email/trigger-email'; @@ -55,7 +56,7 @@ export class TaskItemAssignmentNotifierService { const [organization, assigneeMember, assignedByUser] = await Promise.all([ db.organization.findUnique({ where: { id: organizationId }, - select: { name: true }, + select: { name: true, isInternal: true }, }), db.member.findUnique({ where: { id: assigneeMemberId }, @@ -93,11 +94,17 @@ export class TaskItemAssignmentNotifierService { } // Skip notifications for platform admin members unless they are an owner + // (or this is an internal org, where platform admins are real members) const isOwner = assigneeMember.role ?.split(',') .map((r: string) => r.trim()) .includes('owner'); - if (assigneeUser.role === 'admin' && !isOwner) { + if ( + !isOrgParticipant(assigneeUser.role, { + orgIsInternal: organization?.isInternal ?? false, + }) && + !isOwner + ) { this.logger.log( `Skipping assignment notification: assignee ${assigneeUser.email} is a platform admin (non-owner)`, ); diff --git a/apps/api/src/task-management/task-item-mention-notifier.service.ts b/apps/api/src/task-management/task-item-mention-notifier.service.ts index b45c6605f..95b30d8cc 100644 --- a/apps/api/src/task-management/task-item-mention-notifier.service.ts +++ b/apps/api/src/task-management/task-item-mention-notifier.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { db } from '@db'; +import { orgParticipantMemberWhere } from '../utils/org-participation'; import { isUserUnsubscribed } from '@trycompai/email'; import { triggerEmail } from '../email/trigger-email'; import { TaskItemMentionedEmail } from '../email/templates/task-item-mentioned'; @@ -50,16 +51,15 @@ export class TaskItemMentionNotifierService { return; } - // Get mentioned users: exclude platform admins unless they are an owner of this org + // Get mentioned users: exclude platform admins unless they are an owner of + // this org (or the org is internal, where platform admins are real members) + const participantWhere = await orgParticipantMemberWhere(organizationId); const mentionedMembers = await db.member.findMany({ where: { organizationId, deactivated: false, user: { id: { in: mentionedUserIds } }, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + ...participantWhere, }, include: { user: true }, }); diff --git a/apps/api/src/task-management/task-management.service.ts b/apps/api/src/task-management/task-management.service.ts index 9960c1504..e981e4898 100644 --- a/apps/api/src/task-management/task-management.service.ts +++ b/apps/api/src/task-management/task-management.service.ts @@ -1,5 +1,6 @@ import { db } from '@db'; import { TaskItemEntityType, TaskItemStatus, TaskItemPriority } from '@db'; +import { isMemberOrgParticipant } from '../utils/org-participation'; import { BadRequestException, @@ -54,7 +55,11 @@ export class TaskManagementService { orderBy: { createdAt: 'asc' }, }); if (member?.userId) { - return { memberId: member.id, userId: member.userId, viaApiKey: true }; + return { + memberId: member.id, + userId: member.userId, + viaApiKey: true, + }; } } throw new BadRequestException( @@ -304,7 +309,13 @@ export class TaskManagementService { where: { id: createTaskItemDto.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.role === 'admin') { + if ( + assigneeMember && + !(await isMemberOrgParticipant( + assigneeMember.user.role, + organizationId, + )) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); @@ -513,7 +524,13 @@ export class TaskManagementService { where: { id: updateTaskItemDto.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.role === 'admin') { + if ( + assigneeMember && + !(await isMemberOrgParticipant( + assigneeMember.user.role, + organizationId, + )) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); diff --git a/apps/api/src/tasks/task-notifier.service.ts b/apps/api/src/tasks/task-notifier.service.ts index 2ace0f781..721e83bf9 100644 --- a/apps/api/src/tasks/task-notifier.service.ts +++ b/apps/api/src/tasks/task-notifier.service.ts @@ -1,4 +1,5 @@ import { db } from '@db'; +import { orgParticipantMemberWhere } from '../utils/org-participation'; import { Injectable, Logger } from '@nestjs/common'; import { TaskStatus } from '@db'; import { isUserUnsubscribed } from '@trycompai/email'; @@ -1085,6 +1086,7 @@ export class TaskNotifierService { } = params; try { + const participantWhere = await orgParticipantMemberWhere(organizationId); const [organization, task, allMembers] = await Promise.all([ db.organization.findUnique({ where: { id: organizationId }, @@ -1110,10 +1112,7 @@ export class TaskNotifierService { where: { organizationId, deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + ...participantWhere, }, select: { id: true, @@ -1289,6 +1288,7 @@ export class TaskNotifierService { try { const taskIds = failedTasks.map((t) => t.taskId); + const participantWhere = await orgParticipantMemberWhere(organizationId); const [organization, tasks, allMembers] = await Promise.all([ db.organization.findUnique({ @@ -1319,10 +1319,7 @@ export class TaskNotifierService { where: { organizationId, deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + ...participantWhere, }, select: { id: true, diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index f6cfa76ba..771d624d3 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -12,6 +12,7 @@ import { TaskResponseDto } from './dto/task-responses.dto'; import { TaskNotifierService } from './task-notifier.service'; import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; import { TimelinesService } from '../timelines/timelines.service'; +import { isMemberOrgParticipant } from '../utils/org-participation'; function computeNextTaskReviewDate( frequency: TaskFrequency | null | undefined, @@ -472,7 +473,13 @@ export class TasksService { where: { id: assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.role === 'admin') { + if ( + assigneeMember && + !(await isMemberOrgParticipant( + assigneeMember.user.role, + organizationId, + )) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); @@ -675,7 +682,13 @@ export class TasksService { where: { id: updateData.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.role === 'admin') { + if ( + assigneeMember && + !(await isMemberOrgParticipant( + assigneeMember.user.role, + organizationId, + )) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); @@ -761,8 +774,7 @@ export class TasksService { newValue: updateData.status, ...(updateData.status === TaskStatus.not_relevant && updateData.notRelevantJustification && { - notRelevantJustification: - updateData.notRelevantJustification, + notRelevantJustification: updateData.notRelevantJustification, }), }, }, @@ -1056,7 +1068,7 @@ export class TasksService { throw new BadRequestException('Approver not found or is deactivated'); } - if (approver.user.role === 'admin') { + if (!(await isMemberOrgParticipant(approver.user.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as approver', ); @@ -1135,7 +1147,7 @@ export class TasksService { throw new BadRequestException('Approver not found or is deactivated'); } - if (approver.user.role === 'admin') { + if (!(await isMemberOrgParticipant(approver.user.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as approver', ); diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.ts index eb649e6c6..b079a2279 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automation.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automation.ts @@ -40,11 +40,15 @@ async function sendTaskStatusChangeEmails(params: { try { // Get organization, task assignee, and org owners - const [organization, task, allMembers] = await Promise.all([ - db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }), + // Internal (platform-operated) orgs treat platform admins as real members, + // so they should also receive task notifications. Resolve before building + // the member query since the where-clause depends on it. + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true, isInternal: true }, + }); + const orgIsInternal = organization?.isInternal ?? false; + const [task, allMembers] = await Promise.all([ db.task.findUnique({ where: { id: taskId }, select: { @@ -65,7 +69,7 @@ async function sendTaskStatusChangeEmails(params: { where: { organizationId, deactivated: false, - user: { role: { not: 'admin' } }, + ...(orgIsInternal ? {} : { user: { role: { not: 'admin' } } }), }, select: { role: true, diff --git a/apps/api/src/utils/compliance-filters.ts b/apps/api/src/utils/compliance-filters.ts index dd64b3c94..c352aebe0 100644 --- a/apps/api/src/utils/compliance-filters.ts +++ b/apps/api/src/utils/compliance-filters.ts @@ -2,8 +2,10 @@ import { BUILT_IN_ROLE_OBLIGATIONS, type RoleObligations, allRoles, + isOrgParticipant, } from '@trycompai/auth'; import { db } from '@db'; +import { getOrgIsInternal } from './org-participation'; /** * Check if any of the given role names have the compliance obligation, @@ -42,6 +44,8 @@ export async function filterComplianceMembers( ): Promise { if (members.length === 0) return []; + const orgIsInternal = await getOrgIsInternal(organizationId); + // Collect all custom role names const allCustomRoleNames = new Set(); const builtInRoleNames = new Set(Object.keys(allRoles)); @@ -75,8 +79,9 @@ export async function filterComplianceMembers( return memberRoles .filter(({ member, roleNames }) => { - // Platform admins are excluded — they join customer orgs to debug - if (member.user?.role === 'admin') return false; + // Platform admins are excluded — they join customer orgs to debug — unless + // this is an internal (platform-operated) org where they are real members. + if (!isOrgParticipant(member.user?.role, { orgIsInternal })) return false; return hasComplianceObligation(roleNames, customObligationMap); }) .map(({ member }) => member); diff --git a/apps/api/src/utils/org-participation.spec.ts b/apps/api/src/utils/org-participation.spec.ts new file mode 100644 index 000000000..32d11d3d4 --- /dev/null +++ b/apps/api/src/utils/org-participation.spec.ts @@ -0,0 +1,73 @@ +jest.mock('@db', () => ({ + db: { organization: { findUnique: jest.fn() } }, + Prisma: {}, +})); + +import { db } from '@db'; +import { + getOrgIsInternal, + isMemberOrgParticipant, + orgParticipantMemberWhere, +} from './org-participation'; + +const orgFindUnique = db.organization.findUnique as jest.Mock; + +describe('org-participation', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('getOrgIsInternal', () => { + it('returns true when the org is internal', async () => { + orgFindUnique.mockResolvedValue({ isInternal: true }); + await expect(getOrgIsInternal('org_1')).resolves.toBe(true); + }); + + it('returns false when the org is not internal', async () => { + orgFindUnique.mockResolvedValue({ isInternal: false }); + await expect(getOrgIsInternal('org_1')).resolves.toBe(false); + }); + + it('returns false when the org is not found', async () => { + orgFindUnique.mockResolvedValue(null); + await expect(getOrgIsInternal('missing')).resolves.toBe(false); + }); + }); + + describe('isMemberOrgParticipant', () => { + it('excludes a platform admin in a customer org', async () => { + orgFindUnique.mockResolvedValue({ isInternal: false }); + await expect(isMemberOrgParticipant('admin', 'org_1')).resolves.toBe( + false, + ); + }); + + it('includes a platform admin in an internal org', async () => { + orgFindUnique.mockResolvedValue({ isInternal: true }); + await expect(isMemberOrgParticipant('admin', 'org_1')).resolves.toBe( + true, + ); + }); + + it('includes non-admins regardless of org type', async () => { + orgFindUnique.mockResolvedValue({ isInternal: false }); + await expect(isMemberOrgParticipant('user', 'org_1')).resolves.toBe(true); + await expect(isMemberOrgParticipant(null, 'org_1')).resolves.toBe(true); + }); + }); + + describe('orgParticipantMemberWhere', () => { + it('returns an empty fragment for internal orgs (no exclusion)', async () => { + orgFindUnique.mockResolvedValue({ isInternal: true }); + await expect(orgParticipantMemberWhere('org_1')).resolves.toEqual({}); + }); + + it('excludes platform admins but keeps owner-admins for customer orgs', async () => { + orgFindUnique.mockResolvedValue({ isInternal: false }); + await expect(orgParticipantMemberWhere('org_1')).resolves.toEqual({ + OR: [ + { user: { role: { not: 'admin' } } }, + { role: { contains: 'owner' } }, + ], + }); + }); + }); +}); diff --git a/apps/api/src/utils/org-participation.ts b/apps/api/src/utils/org-participation.ts new file mode 100644 index 000000000..d8183a514 --- /dev/null +++ b/apps/api/src/utils/org-participation.ts @@ -0,0 +1,54 @@ +import { db, Prisma } from '@db'; +// Import from the dedicated subpath (not the package index) so this stays free +// of better-auth — keeps the util light and Jest-loadable without ESM interop. +import { + PLATFORM_ADMIN_ROLE, + isOrgParticipant, +} from '@trycompai/auth/participation'; + +/** + * Resolve whether an organization is platform-operated ("internal", e.g. Comp + * AI's own org). Internal orgs treat platform admins as real participants. + */ +export async function getOrgIsInternal( + organizationId: string, +): Promise { + const org = await db.organization.findUnique({ + where: { id: organizationId }, + select: { isInternal: true }, + }); + return org?.isInternal ?? false; +} + +/** + * Whether a member with the given global `User.role` may participate in the org + * (be an assignee/approver, count toward compliance, receive notifications). + * Fetches the org's internal flag, then delegates to the shared predicate. + */ +export async function isMemberOrgParticipant( + userRole: string | null | undefined, + organizationId: string, +): Promise { + return isOrgParticipant(userRole, { + orgIsInternal: await getOrgIsInternal(organizationId), + }); +} + +/** + * A Prisma `Member` where-fragment that keeps only org participants — i.e. + * everyone except platform admins, but still including platform admins who are + * org owners (they opted into this org). Spread it into a member query's + * `where`. Returns an empty fragment for internal orgs, so platform admins are + * included there. Use for recipient/participant lists (notifications, devices). + */ +export async function orgParticipantMemberWhere( + organizationId: string, +): Promise { + if (await getOrgIsInternal(organizationId)) return {}; + return { + OR: [ + { user: { role: { not: PLATFORM_ADMIN_ROLE } } }, + { role: { contains: 'owner' } }, + ], + }; +} diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts index 349a2cff9..0b2947742 100644 --- a/apps/api/src/vendors/vendors.service.ts +++ b/apps/api/src/vendors/vendors.service.ts @@ -12,6 +12,7 @@ import { Prisma } from '@db'; import type { TriggerVendorRiskAssessmentVendorDto } from './dto/trigger-vendor-risk-assessment.dto'; import { resolveTaskCreatorAndAssignee } from '../trigger/vendor/vendor-risk-assessment/assignee'; import { resolveStrategyDescriptionUpdate } from '../risks/strategy-descriptions'; +import { isMemberOrgParticipant } from '../utils/org-participation'; const normalizeWebsite = ( website: string | null | undefined, @@ -225,7 +226,10 @@ export class VendorsService { where: { id: assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (member?.user.role === 'admin') { + if ( + member && + !(await isMemberOrgParticipant(member.user.role, organizationId)) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); diff --git a/apps/app/src/actions/policies/accept-requested-policy-changes.ts b/apps/app/src/actions/policies/accept-requested-policy-changes.ts index 1d7a7c460..24814a8db 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -1,5 +1,6 @@ 'use server'; +import { getOrgIsInternal } from '@/lib/org-participation'; import { sendNewPolicyEmail } from '@/trigger/tasks/email/new-policy-email'; import { db, PolicyStatus, type Prisma } from '@db/server'; import { tasks } from '@trigger.dev/sdk'; @@ -102,12 +103,13 @@ export const acceptRequestedPolicyChangesAction = authActionClient // Get all active members — the downstream isUserUnsubscribed check // handles role-based notification filtering via the org's notification matrix. + const orgIsInternal = await getOrgIsInternal(session.activeOrganizationId); const members = await db.member.findMany({ where: { organizationId: session.activeOrganizationId, isActive: true, deactivated: false, - user: { role: { not: 'admin' } }, + ...(orgIsInternal ? {} : { user: { role: { not: 'admin' } } }), }, include: { user: true, diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx index 31b90ec8f..44dee3237 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx @@ -55,6 +55,7 @@ export interface AdminOrgDetail { onboardingCompleted: boolean; website: string | null; backgroundCheckStepEnabled: boolean; + isInternal: boolean; members: OrgMember[]; } diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx index 0d9b21b3d..ab39dce8b 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx @@ -1,18 +1,12 @@ 'use client'; +import { RecentAuditLogs } from '@/components/RecentAuditLogs'; +import type { AuditLogWithRelations } from '@/hooks/use-audit-logs'; +import { apiClient } from '@/lib/api-client'; +import { Badge, Section, Stack, Switch, Text } from '@trycompai/design-system'; import { useState } from 'react'; import { toast } from 'sonner'; import useSWR from 'swr'; -import { apiClient } from '@/lib/api-client'; -import { RecentAuditLogs } from '@/components/RecentAuditLogs'; -import type { AuditLogWithRelations } from '@/hooks/use-audit-logs'; -import { - Badge, - Section, - Stack, - Switch, - Text, -} from '@trycompai/design-system'; interface AdminOrgDetail { id: string; @@ -22,6 +16,7 @@ interface AdminOrgDetail { onboardingCompleted: boolean; members: { id: string }[]; backgroundCheckStepEnabled: boolean; + isInternal: boolean; } export function OrganizationDetail({ @@ -32,20 +27,19 @@ export function OrganizationDetail({ currentOrgId: string; hasAccess: boolean; }) { - const [bgCheckEnabled, setBgCheckEnabled] = useState( - org.backgroundCheckStepEnabled, - ); + const [bgCheckEnabled, setBgCheckEnabled] = useState(org.backgroundCheckStepEnabled); const [savingBgCheck, setSavingBgCheck] = useState(false); + const [isInternal, setIsInternal] = useState(org.isInternal); + const [savingInternal, setSavingInternal] = useState(false); const handleToggleBgCheck = async (next: boolean) => { const previous = bgCheckEnabled; setBgCheckEnabled(next); setSavingBgCheck(true); - const res = await apiClient.patch( - `/v1/admin/organizations/${org.id}`, - { backgroundCheckStepEnabled: next }, - ); + const res = await apiClient.patch(`/v1/admin/organizations/${org.id}`, { + backgroundCheckStepEnabled: next, + }); setSavingBgCheck(false); @@ -55,10 +49,32 @@ export function OrganizationDetail({ return; } + toast.success( + next ? 'Background checks now required' : 'Background checks bypassed for this organization', + ); + }; + + const handleToggleInternal = async (next: boolean) => { + const previous = isInternal; + setIsInternal(next); + setSavingInternal(true); + + const res = await apiClient.patch(`/v1/admin/organizations/${org.id}`, { + isInternal: next, + }); + + setSavingInternal(false); + + if (res.error) { + setIsInternal(previous); + toast.error('Failed to update internal-organization setting'); + return; + } + toast.success( next - ? 'Background checks now required' - : 'Background checks bypassed for this organization', + ? 'Marked as internal — platform admins can now participate here' + : 'Unmarked as internal — platform admins are excluded again', ); }; @@ -81,14 +97,8 @@ export function OrganizationDetail({ variant={hasAccess ? 'default' : 'destructive'} /> - - + +
@@ -96,9 +106,8 @@ export function OrganizationDetail({
Require background checks - When off, this org's members do not need to pass a background - check to count toward people completion. Existing requests stay - accessible. + When off, this org's members do not need to pass a background check to count + toward people completion. Existing requests stay accessible.
+
+
+
+ Internal organization + + For Comp AI-operated orgs only. When on, platform admins are treated as real members + here — assignable, counted in compliance, and notified. Leave off for every customer + organization. + +
+ +
+
+ {isLoading ? (
diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index b284e75be..ea58ead24 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -1,22 +1,16 @@ import { getFeatureFlags } from '@/app/posthog'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; +import { OrgInternalProvider } from '@/components/org-internal-context'; import { TriggerTokenProvider } from '@/components/trigger-token-provider'; import { serverApi } from '@/lib/api-server'; -import { - canAccessApp, - canAccessAuditorView, - parseRolesString, -} from '@/lib/permissions'; -import { - resolveCustomRolePermissions, - resolveUserPermissions, -} from '@/lib/permissions.server'; +import { canAccessApp, canAccessAuditorView, parseRolesString } from '@/lib/permissions'; +import { resolveCustomRolePermissions, resolveUserPermissions } from '@/lib/permissions.server'; +import { getSignedUrl } from '@/lib/s3-presigner'; import type { OrganizationFromMe } from '@/types'; import { auth } from '@/utils/auth'; import { GetObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@/lib/s3-presigner'; -import { OrganizationIdentifier, ServerFeatureFlagsProvider } from '@trycompai/analytics'; import { db, Role } from '@db/server'; +import { OrganizationIdentifier, ServerFeatureFlagsProvider } from '@trycompai/analytics'; import dynamic from 'next/dynamic'; import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -170,14 +164,8 @@ export default async function Layout({ // audit:read — built-in `auditor` role OR a custom role with explicit // audit:read. Resolve the custom-role permissions once so we don't // second-guess the owner/admin's implicit all-permissions in the UI. - const customRolePermissions = await resolveCustomRolePermissions( - member.role, - requestedOrgId, - ); - const auditorViewVisible = canAccessAuditorView( - member.role, - customRolePermissions, - ); + const customRolePermissions = await resolveCustomRolePermissions(member.role, requestedOrgId); + const auditorViewVisible = canAccessAuditorView(member.role, customRolePermissions); // User data for navbar const user = { @@ -193,25 +181,27 @@ export default async function Layout({ > - - {children} - + + + {children} + + diff --git a/apps/app/src/app/api/people/agent-devices/route.ts b/apps/app/src/app/api/people/agent-devices/route.ts index c44fa85ae..9bdc95741 100644 --- a/apps/app/src/app/api/people/agent-devices/route.ts +++ b/apps/app/src/app/api/people/agent-devices/route.ts @@ -1,11 +1,9 @@ +import type { CheckDetails, DeviceWithChecks } from '@/app/(app)/[orgId]/people/devices/types'; +import { getOrgIsInternal } from '@/lib/org-participation'; +import { requireApiPermission } from '@/lib/permissions.server'; import { db } from '@db/server'; +import { daysSinceCheckIn, getDeviceComplianceStatus } from '@trycompai/utils/devices'; import { NextResponse } from 'next/server'; -import { requireApiPermission } from '@/lib/permissions.server'; -import { - daysSinceCheckIn, - getDeviceComplianceStatus, -} from '@trycompai/utils/devices'; -import type { CheckDetails, DeviceWithChecks } from '@/app/(app)/[orgId]/people/devices/types'; /** Maps the DB `DeviceSource` enum to the frontend source discriminant. */ function mapSource(source: string): DeviceWithChecks['source'] { @@ -23,12 +21,13 @@ export async function GET(req: Request) { if (ctx instanceof NextResponse) return ctx; const { organizationId } = ctx; + const orgIsInternal = await getOrgIsInternal(organizationId); const devices = await db.device.findMany({ where: { organizationId, member: { deactivated: false, - NOT: { user: { role: 'admin' } }, + ...(orgIsInternal ? {} : { NOT: { user: { role: 'admin' } } }), }, }, include: { diff --git a/apps/app/src/app/api/people/fleet-hosts/route.ts b/apps/app/src/app/api/people/fleet-hosts/route.ts index 4046f585b..abe6e9ac2 100644 --- a/apps/app/src/app/api/people/fleet-hosts/route.ts +++ b/apps/app/src/app/api/people/fleet-hosts/route.ts @@ -1,8 +1,9 @@ +import type { Host } from '@/app/(app)/[orgId]/people/devices/types'; import { getFleetInstance } from '@/lib/fleet'; +import { getOrgIsInternal } from '@/lib/org-participation'; +import { requireApiPermission } from '@/lib/permissions.server'; import { db } from '@db/server'; import { NextResponse } from 'next/server'; -import { requireApiPermission } from '@/lib/permissions.server'; -import type { Host } from '@/app/(app)/[orgId]/people/devices/types'; const MDM_POLICY_ID = -9999; @@ -15,11 +16,12 @@ export async function GET(req: Request) { const fleet = await getFleetInstance(); + const orgIsInternal = await getOrgIsInternal(organizationId); const employees = await db.member.findMany({ where: { organizationId, deactivated: false, - NOT: { user: { role: 'admin' } }, + ...(orgIsInternal ? {} : { NOT: { user: { role: 'admin' } } }), }, include: { user: true }, }); @@ -70,48 +72,43 @@ export async function GET(req: Request) { } } - const data: Host[] = devices.map( - (device: { data: { host: Host } }, index: number) => { - const host = device.data.host; - const platform = host.platform?.toLowerCase(); - const osVersion = host.os_version?.toLowerCase(); - const isMacOS = - platform === 'darwin' || - platform === 'macos' || - platform === 'osx' || - osVersion?.includes('mac'); - return { - ...host, - user_name: userNames[index], - member_id: memberIds[index], - policies: [ - ...(host.policies || []), - ...(isMacOS - ? [ - { - id: MDM_POLICY_ID, - name: 'MDM Enabled', - response: host.mdm?.connected_to_fleet ? 'pass' : 'fail', - }, - ] - : []), - ].map((policy) => { - const policyResult = resultIndex.get( - `${userIds[index]}:${policy.id}`, - ); - return { - ...policy, - response: - policy.response === 'pass' || - policyResult?.fleetPolicyResponse === 'pass' - ? 'pass' - : 'fail', - attachments: policyResult?.attachments || [], - }; - }), - }; - }, - ); + const data: Host[] = devices.map((device: { data: { host: Host } }, index: number) => { + const host = device.data.host; + const platform = host.platform?.toLowerCase(); + const osVersion = host.os_version?.toLowerCase(); + const isMacOS = + platform === 'darwin' || + platform === 'macos' || + platform === 'osx' || + osVersion?.includes('mac'); + return { + ...host, + user_name: userNames[index], + member_id: memberIds[index], + policies: [ + ...(host.policies || []), + ...(isMacOS + ? [ + { + id: MDM_POLICY_ID, + name: 'MDM Enabled', + response: host.mdm?.connected_to_fleet ? 'pass' : 'fail', + }, + ] + : []), + ].map((policy) => { + const policyResult = resultIndex.get(`${userIds[index]}:${policy.id}`); + return { + ...policy, + response: + policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' + ? 'pass' + : 'fail', + attachments: policyResult?.attachments || [], + }; + }), + }; + }); return NextResponse.json({ data }); } diff --git a/apps/app/src/components/SelectAssignee.tsx b/apps/app/src/components/SelectAssignee.tsx index 0dcece026..1c1f09f43 100644 --- a/apps/app/src/components/SelectAssignee.tsx +++ b/apps/app/src/components/SelectAssignee.tsx @@ -1,7 +1,8 @@ +import { useOrgIsInternal } from '@/components/org-internal-context'; import { authClient } from '@/utils/auth-client'; +import { Member, User } from '@db'; import { Avatar, AvatarFallback, AvatarImage } from '@trycompai/ui/avatar'; import { Select, SelectContent, SelectItem, SelectTrigger } from '@trycompai/ui/select'; -import { Member, User } from '@db'; import { UserIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; @@ -21,13 +22,13 @@ export const SelectAssignee = ({ withTitle = true, }: SelectAssigneeProps) => { const { data: activeMember } = authClient.useActiveMember(); - // Exclude platform admins from assignee selection + const orgIsInternal = useOrgIsInternal(); + // Exclude platform admins from assignee selection — except in internal + // (platform-operated) orgs, where they are real members. const assignees = rawAssignees - .filter((a) => a.user.role !== 'admin') + .filter((a) => orgIsInternal || a.user.role !== 'admin') .sort((a, b) => - (a.user.name || a.user.email || '').localeCompare( - b.user.name || b.user.email || '', - ), + (a.user.name || a.user.email || '').localeCompare(b.user.name || b.user.email || ''), ); const [selectedAssignee, setSelectedAssignee] = useState<(Member & { user: User }) | null>(null); @@ -124,11 +125,7 @@ export const SelectAssignee = ({
{assignees.map((assignee) => ( - +
(false); + +export function OrgInternalProvider({ + isInternal, + children, +}: { + isInternal: boolean; + children: ReactNode; +}) { + return ( + {children} + ); +} + +export function useOrgIsInternal(): boolean { + return useContext(OrgIsInternalContext); +} diff --git a/apps/app/src/lib/compliance.ts b/apps/app/src/lib/compliance.ts index d916451fb..6ce5efc98 100644 --- a/apps/app/src/lib/compliance.ts +++ b/apps/app/src/lib/compliance.ts @@ -1,7 +1,8 @@ import 'server-only'; -import { BUILT_IN_ROLE_OBLIGATIONS } from '@trycompai/auth'; import { db } from '@db/server'; +import { BUILT_IN_ROLE_OBLIGATIONS } from '@trycompai/auth'; +import { getOrgIsInternal, isOrgParticipant } from './org-participation'; import { type UserPermissions, canAccessApp, @@ -51,9 +52,7 @@ async function filterMembersByPermission( .filter((r) => r.permissions) .map((r) => { const parsed = - typeof r.permissions === 'string' - ? JSON.parse(r.permissions) - : r.permissions; + typeof r.permissions === 'string' ? JSON.parse(r.permissions) : r.permissions; return [r.name, parsed as Record]; }), ); @@ -81,6 +80,8 @@ export async function filterComplianceMembers( ): Promise { if (members.length === 0) return []; + const orgIsInternal = await getOrgIsInternal(organizationId); + const builtInRoleNames = new Set(Object.keys(BUILT_IN_ROLE_OBLIGATIONS)); const allRoleNames = new Set(); @@ -101,9 +102,8 @@ export async function filterComplianceMembers( }); obligationMap = Object.fromEntries( dbRoles.map((r) => { - const obligations = typeof r.obligations === 'string' - ? JSON.parse(r.obligations) - : (r.obligations || {}); + const obligations = + typeof r.obligations === 'string' ? JSON.parse(r.obligations) : r.obligations || {}; return [r.name, obligations as Record]; }), ); @@ -111,8 +111,9 @@ export async function filterComplianceMembers( return memberRoles .filter(({ member, roleNames }) => { - // Platform admins are excluded — they join customer orgs to debug - if (member.user?.role === 'admin') return false; + // Platform admins are excluded — they join customer orgs to debug — unless + // this is an internal (platform-operated) org where they are real members. + if (!isOrgParticipant(member.user?.role, { orgIsInternal })) return false; for (const name of roleNames) { // DB override wins, but only if `compliance` is explicitly set — // otherwise fall back to the hardcoded built-in default. diff --git a/apps/app/src/lib/org-participation-rule.test.ts b/apps/app/src/lib/org-participation-rule.test.ts new file mode 100644 index 000000000..945c429c6 --- /dev/null +++ b/apps/app/src/lib/org-participation-rule.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { isOrgParticipant, PLATFORM_ADMIN_ROLE } from './org-participation-rule'; + +describe('isOrgParticipant', () => { + it('excludes platform admins in a customer org', () => { + expect(isOrgParticipant(PLATFORM_ADMIN_ROLE, { orgIsInternal: false })).toBe(false); + }); + + it('includes platform admins in an internal org', () => { + expect(isOrgParticipant(PLATFORM_ADMIN_ROLE, { orgIsInternal: true })).toBe(true); + }); + + it('includes non-admin roles in any org', () => { + expect(isOrgParticipant('user', { orgIsInternal: false })).toBe(true); + expect(isOrgParticipant('owner', { orgIsInternal: false })).toBe(true); + }); + + it('treats null/undefined roles as participants (not platform admins)', () => { + expect(isOrgParticipant(null, { orgIsInternal: false })).toBe(true); + expect(isOrgParticipant(undefined, { orgIsInternal: false })).toBe(true); + }); + + it('includes everyone in an internal org', () => { + expect(isOrgParticipant('admin', { orgIsInternal: true })).toBe(true); + expect(isOrgParticipant('user', { orgIsInternal: true })).toBe(true); + expect(isOrgParticipant(null, { orgIsInternal: true })).toBe(true); + }); +}); diff --git a/apps/app/src/lib/org-participation-rule.ts b/apps/app/src/lib/org-participation-rule.ts new file mode 100644 index 000000000..e3cebdf8d --- /dev/null +++ b/apps/app/src/lib/org-participation-rule.ts @@ -0,0 +1,23 @@ +/** + * Pure, dependency-free org-participation rule. + * + * This is a deliberate mirror of `packages/auth/src/participation.ts`. The app + * cannot import `@trycompai/auth` from files that end up in the Trigger.dev + * bundle (that package pulls in better-auth, which the deploy pipeline can't + * bundle — see policy-acknowledgment-digest-helpers.ts for the same pattern). + * Keeping this rule free of imports lets both RSC/server code and Trigger.dev + * tasks share one implementation. KEEP IN SYNC with the auth package version. + * + * Platform admins (`User.role === 'admin'`) are Comp AI staff embedded in + * customer orgs for support; they are excluded from an org's business logic + * UNLESS the org is internal (platform-operated, e.g. Comp AI's own org). + */ +export const PLATFORM_ADMIN_ROLE = 'admin'; + +export function isOrgParticipant( + userRole: string | null | undefined, + { orgIsInternal }: { orgIsInternal: boolean }, +): boolean { + if (orgIsInternal) return true; + return userRole !== PLATFORM_ADMIN_ROLE; +} diff --git a/apps/app/src/lib/org-participation.ts b/apps/app/src/lib/org-participation.ts new file mode 100644 index 000000000..7f9769653 --- /dev/null +++ b/apps/app/src/lib/org-participation.ts @@ -0,0 +1,20 @@ +import { db } from '@db/server'; +import { isOrgParticipant } from './org-participation-rule'; + +/** + * App-side mirror of the API's org-participation helpers. See + * `packages/auth/src/participation.ts` for the shared rule: platform admins are + * Comp AI staff embedded in customer orgs and are excluded from an org's + * business logic, UNLESS the org is internal (platform-operated, e.g. Comp AI's + * own org) where they are real members. + */ +export { isOrgParticipant }; + +/** Whether an org is platform-operated ("internal", e.g. Comp AI's own org). */ +export async function getOrgIsInternal(organizationId: string): Promise { + const org = await db.organization.findUnique({ + where: { id: organizationId }, + select: { isInternal: true }, + }); + return org?.isInternal ?? false; +} diff --git a/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts b/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts index f7f2662fa..8ee45042e 100644 --- a/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts +++ b/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts @@ -1,3 +1,4 @@ +import { isOrgParticipant } from '@/lib/org-participation-rule'; import { VendorStatus, db } from '@db/server'; import { logger, metadata, queue, tags, task, tasks } from '@trigger.dev/sdk'; import axios from 'axios'; @@ -49,12 +50,22 @@ export const generateVendorMitigation = task({ // platform admins are hidden from the assignee UI, so skip them too. let assigneeUpdate: { assigneeId: string | null } | Record = {}; if (authorId) { - const author = await db.member.findFirst({ - where: { id: authorId, organizationId }, - include: { user: { select: { role: true } } }, - }); + const [author, org] = await Promise.all([ + db.member.findFirst({ + where: { id: authorId, organizationId }, + include: { user: { select: { role: true } } }, + }), + db.organization.findUnique({ + where: { id: organizationId }, + select: { isInternal: true }, + }), + ]); assigneeUpdate = { - assigneeId: author?.user.role === 'admin' ? null : authorId, + assigneeId: isOrgParticipant(author?.user.role, { + orgIsInternal: org?.isInternal ?? false, + }) + ? authorId + : null, }; } diff --git a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts index da2608801..8cea31d1d 100644 --- a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts @@ -2,6 +2,7 @@ * Helper types and pure filter function for the policy acknowledgment digest. * Extracted from the scheduled task for testability. */ +import { isOrgParticipant } from '@/lib/org-participation-rule'; import type { PolicyVisibility } from '@db'; // Inlined from @trycompai/auth to avoid pulling that package into the Trigger.dev bundle. @@ -34,6 +35,7 @@ export async function filterDigestMembersByCompliance( db: ComplianceFilterDb, members: T[], organizationId: string, + orgIsInternal = false, ): Promise { if (members.length === 0) return []; @@ -58,9 +60,7 @@ export async function filterDigestMembersByCompliance( obligationMap = Object.fromEntries( dbRoles.map((r) => { const obligations = - typeof r.obligations === 'string' - ? JSON.parse(r.obligations) - : r.obligations || {}; + typeof r.obligations === 'string' ? JSON.parse(r.obligations) : r.obligations || {}; return [r.name, obligations as { compliance?: boolean }]; }), ); @@ -68,8 +68,9 @@ export async function filterDigestMembersByCompliance( return parsed .filter(({ member, roleNames }) => { - // Platform admins are excluded — matches the @/lib/compliance behavior. - if (member.user?.role === 'admin') return false; + // Platform admins are excluded — matches the @/lib/compliance behavior — + // unless this is an internal (platform-operated) org. + if (!isOrgParticipant(member.user?.role, { orgIsInternal })) return false; for (const name of roleNames) { // DB override wins, but only if `compliance` is explicitly set — // otherwise fall back to the hardcoded built-in default. diff --git a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts index 3d8c293a4..b22e1868f 100644 --- a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts @@ -18,10 +18,7 @@ import { } from './policy-acknowledgment-digest-helpers'; const getPortalBase = () => - (process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai').replace( - /\/+$/, - '', - ); + (process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai').replace(/\/+$/, ''); // Skip orgs that look abandoned — same threshold weekly-task-reminder uses so // we don't keep hitting dead addresses and burning domain reputation. @@ -43,9 +40,7 @@ export const policyAcknowledgmentDigest = schedules.task({ maxDuration: 1000 * 60 * 15, // 15 minutes run: async () => { const inactivityCutoff = new Date(); - inactivityCutoff.setDate( - inactivityCutoff.getDate() - ORG_INACTIVITY_DAYS, - ); + inactivityCutoff.setDate(inactivityCutoff.getDate() - ORG_INACTIVITY_DAYS); const organizations = await db.organization.findMany({ where: { @@ -72,6 +67,7 @@ export const policyAcknowledgmentDigest = schedules.task({ select: { id: true, name: true, + isInternal: true, policy: { where: { status: 'published', @@ -124,6 +120,7 @@ export const policyAcknowledgmentDigest = schedules.task({ db as unknown as ComplianceFilterDb, org.members, org.id, + org.isInternal, ); if (complianceMembers.length === 0) continue; @@ -150,9 +147,7 @@ export const policyAcknowledgmentDigest = schedules.task({ if (pendingByMember.length === 0) continue; // One unsubscribe query per org, batched across members. - const emailsWithPending = pendingByMember.map( - (p) => p.member.user.email, - ); + const emailsWithPending = pendingByMember.map((p) => p.member.user.email); const unsubscribedEmails = await getUnsubscribedEmails( db, emailsWithPending, @@ -188,10 +183,7 @@ export const policyAcknowledgmentDigest = schedules.task({ // Render all emails and group by primaryOrgId for batch sending. let emailsSent = 0; let emailsFailed = 0; - const emailsByOrg = new Map< - string, - Array<{ to: string; subject: string; html: string }> - >(); + const emailsByOrg = new Map>(); for (const entry of rollup.values()) { const subject = computePolicyAcknowledgmentDigestSubject(entry.orgs); diff --git a/apps/app/src/trigger/tasks/task/policy-schedule.ts b/apps/app/src/trigger/tasks/task/policy-schedule.ts index 444df004e..17a838040 100644 --- a/apps/app/src/trigger/tasks/task/policy-schedule.ts +++ b/apps/app/src/trigger/tasks/task/policy-schedule.ts @@ -1,3 +1,4 @@ +import { isOrgParticipant } from '@/lib/org-participation-rule'; import { db } from '@db/server'; import { Novu } from '@novu/api'; import { logger, schedules } from '@trigger.dev/sdk'; @@ -30,20 +31,19 @@ export const policySchedule = schedules.task({ select: { id: true, name: true, + isInternal: true, members: { where: { deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], }, select: { + role: true, user: { select: { id: true, name: true, email: true, + role: true, }, }, }, @@ -127,21 +127,38 @@ export const policySchedule = schedules.task({ } >(); const addRecipients = ( - users: Array<{ user: { id: string; email: string; name?: string } }>, + members: Array<{ + role: string; + user: { + id: string; + email: string; + name?: string; + role?: string | null; + }; + }>, policy: (typeof overduePolicies)[number], ) => { - for (const entry of users) { + // Exclude platform admins (Comp AI staff) unless they are an org owner + // or the org is internal — same rule as isOrgParticipant elsewhere. + const orgIsInternal = policy.organization?.isInternal ?? false; + for (const entry of members) { const user = entry.user; - if (user && user.email && user.id) { - const key = `${user.id}-${policy.id}`; - if (!recipientsMap.has(key)) { - recipientsMap.set(key, { - email: user.email, - userId: user.id, - name: user.name ?? '', - policy, - }); - } + if (!user?.email || !user.id) continue; + const isOwner = entry.role + ?.split(',') + .map((r) => r.trim()) + .includes('owner'); + if (!isOrgParticipant(user.role, { orgIsInternal }) && !isOwner) { + continue; + } + const key = `${user.id}-${policy.id}`; + if (!recipientsMap.has(key)) { + recipientsMap.set(key, { + email: user.email, + userId: user.id, + name: user.name ?? '', + policy, + }); } } }; diff --git a/apps/app/src/trigger/tasks/task/task-schedule.ts b/apps/app/src/trigger/tasks/task/task-schedule.ts index 872b3cbd2..a80ac832b 100644 --- a/apps/app/src/trigger/tasks/task/task-schedule.ts +++ b/apps/app/src/trigger/tasks/task/task-schedule.ts @@ -1,3 +1,4 @@ +import { isOrgParticipant } from '@/lib/org-participation-rule'; import { db } from '@db/server'; import { Novu } from '@novu/api'; import { logger, schedules } from '@trigger.dev/sdk'; @@ -33,20 +34,19 @@ export const taskSchedule = schedules.task({ select: { id: true, name: true, + isInternal: true, members: { where: { deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], }, select: { + role: true, user: { select: { id: true, name: true, email: true, + role: true, }, }, }, @@ -196,21 +196,38 @@ export const taskSchedule = schedules.task({ } >(); const addRecipients = ( - users: Array<{ user: { id: string; email: string; name?: string } }>, + members: Array<{ + role: string; + user: { + id: string; + email: string; + name?: string; + role?: string | null; + }; + }>, task: (typeof allUpdatedTasks)[number], ) => { - for (const entry of users) { + // Exclude platform admins (Comp AI staff) unless they are an org owner + // or the org is internal — same rule as isOrgParticipant elsewhere. + const orgIsInternal = task.organization?.isInternal ?? false; + for (const entry of members) { const user = entry.user; - if (user && user.email && user.id) { - const key = `${user.id}-${task.id}`; - if (!recipientsMap.has(key)) { - recipientsMap.set(key, { - email: user.email, - userId: user.id, - name: user.name ?? '', - task, - }); - } + if (!user?.email || !user.id) continue; + const isOwner = entry.role + ?.split(',') + .map((r) => r.trim()) + .includes('owner'); + if (!isOrgParticipant(user.role, { orgIsInternal }) && !isOwner) { + continue; + } + const key = `${user.id}-${task.id}`; + if (!recipientsMap.has(key)) { + recipientsMap.set(key, { + email: user.email, + userId: user.id, + name: user.name ?? '', + task, + }); } } }; @@ -238,7 +255,12 @@ export const taskSchedule = schedules.task({ : ('todo' as const); // Check if user is unsubscribed - const isUnsubscribed = await isUserUnsubscribed(db, recipient.email, 'taskReminders', recipient.task.organizationId); + const isUnsubscribed = await isUserUnsubscribed( + db, + recipient.email, + 'taskReminders', + recipient.task.organizationId, + ); if (isUnsubscribed) { logger.info( diff --git a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts index f1466eac6..397b983ff 100644 --- a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts +++ b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts @@ -1,3 +1,4 @@ +import { isOrgParticipant } from '@/lib/org-participation-rule'; import { db } from '@db/server'; import { logger, schedules } from '@trigger.dev/sdk'; import { sendWeeklyTaskDigestEmailTask } from '../email/weekly-task-digest-email'; @@ -36,21 +37,20 @@ export const weeklyTaskReminder = schedules.task({ select: { id: true, name: true, + isInternal: true, members: { where: { deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], }, select: { id: true, + role: true, user: { select: { id: true, name: true, email: true, + role: true, }, }, }, @@ -58,7 +58,9 @@ export const weeklyTaskReminder = schedules.task({ }, }); - logger.info(`Found ${organizations.length} active organizations to process (skipped orgs with no sessions in ${ORG_INACTIVITY_DAYS} days)`); + logger.info( + `Found ${organizations.length} active organizations to process (skipped orgs with no sessions in ${ORG_INACTIVITY_DAYS} days)`, + ); // Build email payloads for all members with TODO tasks const emailPayloads = []; @@ -97,6 +99,21 @@ export const weeklyTaskReminder = schedules.task({ continue; } + // Exclude platform admins (Comp AI staff) unless they are an org owner + // or the org is internal — same rule as isOrgParticipant elsewhere. + const isOwner = member.role + .split(',') + .map((r) => r.trim()) + .includes('owner'); + if ( + !isOrgParticipant(member.user.role, { + orgIsInternal: org.isInternal, + }) && + !isOwner + ) { + continue; + } + emailPayloads.push({ payload: { email: member.user.email, diff --git a/packages/auth/package.json b/packages/auth/package.json index f561f7c08..cbe88d748 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -13,6 +13,10 @@ "types": "./dist/permissions.d.ts", "default": "./dist/permissions.js" }, + "./participation": { + "types": "./dist/participation.d.ts", + "default": "./dist/participation.js" + }, "./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 489deab2a..465fbba06 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,23 +1,30 @@ export { + BUILT_IN_ROLE_OBLIGATIONS, + BUILT_IN_ROLE_PERMISSIONS, + PRIVILEGED_ROLES, + RESTRICTED_ROLES, + ROLE_HIERARCHY, ac, - statement, - owner, admin, + allRoles, auditor, - employee, contractor, - allRoles, - ROLE_HIERARCHY, - RESTRICTED_ROLES, + employee, isRestrictedRole, - PRIVILEGED_ROLES, - BUILT_IN_ROLE_PERMISSIONS, - BUILT_IN_ROLE_OBLIGATIONS, + owner, + parseRoleObligations, + parseRolePermissions, + statement, type RoleName, type RoleObligations, type RolePermissions, - parseRolePermissions, - parseRoleObligations, } from './permissions'; -export { createAuthServer, type CreateAuthServerOptions, type AuthServer } from './server'; +export { createAuthServer, type AuthServer, type CreateAuthServerOptions } from './server'; + +export { + PLATFORM_ADMIN_ROLE, + isExcludedFromOrgParticipation, + isOrgParticipant, + type OrgParticipationContext, +} from './participation'; diff --git a/packages/auth/src/participation.ts b/packages/auth/src/participation.ts new file mode 100644 index 000000000..ae48fd624 --- /dev/null +++ b/packages/auth/src/participation.ts @@ -0,0 +1,55 @@ +/** + * Organization participation rules. + * + * A *platform admin* is a global Comp AI staff account (`User.role === 'admin'`) + * that can enter any organization for support/debugging. By default such an + * account is NOT a genuine participant of the customer orgs it enters, so it is + * excluded from that org's business logic: it cannot be an assignee/approver, is + * not counted toward compliance progress, and does not receive org-scoped + * notifications. + * + * The single exception is an *internal* organization — one operated by the + * platform itself (e.g. Comp AI's own org). There, the platform admins ARE the + * real members and must be able to run compliance like any other org. + * + * `isOrgParticipant` is the single source of truth for that rule. Do NOT + * reintroduce inline `user.role === 'admin'` participation checks anywhere — + * call this instead so the internal-org exception stays consistent. + * + * SCOPE: this governs *participation* only. It must never be used to decide + * platform-admin access or privileges — those stay with PlatformAdminGuard / + * `isPlatformAdmin`, which are unaffected by an org being internal. + */ +export const PLATFORM_ADMIN_ROLE = 'admin'; + +export interface OrgParticipationContext { + /** Whether the organization is platform-operated (e.g. Comp AI's own org). */ + orgIsInternal: boolean; +} + +/** + * Returns true when `userRole` should be treated as a real participant of an + * organization with the given context. + * + * @param userRole The global `User.role` value (e.g. `'admin'` for platform + * admins, `'user'`/null otherwise). This is NOT the org-scoped member role. + */ +export function isOrgParticipant( + userRole: string | null | undefined, + { orgIsInternal }: OrgParticipationContext, +): boolean { + if (orgIsInternal) return true; + return userRole !== PLATFORM_ADMIN_ROLE; +} + +/** + * Inverse of {@link isOrgParticipant} — true when the user must be excluded from + * the org's participation-level business logic. Convenience for the many call + * sites whose existing shape is "if (isPlatformAdmin) exclude". + */ +export function isExcludedFromOrgParticipation( + userRole: string | null | undefined, + ctx: OrgParticipationContext, +): boolean { + return !isOrgParticipant(userRole, ctx); +} diff --git a/packages/db/prisma/migrations/20260708120000_add_organization_is_internal/migration.sql b/packages/db/prisma/migrations/20260708120000_add_organization_is_internal/migration.sql new file mode 100644 index 000000000..ad838a588 --- /dev/null +++ b/packages/db/prisma/migrations/20260708120000_add_organization_is_internal/migration.sql @@ -0,0 +1,6 @@ +-- Mark platform-operated ("internal") organizations, e.g. Comp AI's own org. +-- When true, platform admins are treated as real participants of the org +-- (assignable, counted in compliance, notified). Defaults to false so every +-- existing and future customer org keeps excluding platform-admin staff. +ALTER TABLE "Organization" + ADD COLUMN "isInternal" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index b1cf46f42..64d51d49d 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -9,6 +9,10 @@ model Organization { website String? onboardingCompleted Boolean @default(false) hasAccess Boolean @default(false) + // Internal (platform-operated) organization — e.g. Comp AI's own org. + // When true, platform admins are treated as real participants of this org + // (assignable, counted in compliance, notified). Off for all customer orgs. + isInternal Boolean @default(false) advancedModeEnabled Boolean @default(false) evidenceApprovalEnabled Boolean @default(false) deviceAgentStepEnabled Boolean @default(true) From 415ee40786aa104638ed34f27d00b94af3485f09 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 8 Jul 2026 20:33:37 -0400 Subject: [PATCH 2/6] =?UTF-8?q?fix(org):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?align=20browser-automation,=20tighten=20null=20validation,=20ke?= =?UTF-8?q?ep=20dep=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run-browser-automation: use the shared `orgParticipantMemberWhere` helper instead of an inline copy, so it matches the other task notifiers (uses the role constant + owner exception) and still includes everyone in internal orgs. - update-admin-organization DTO: reject an explicit `null` isInternal at validation (400) instead of letting it reach the non-null column (500). - org-participation (app): stop re-exporting `isOrgParticipant` from the module that imports `@db/server`; consumers import it from the dependency-free `org-participation-rule` so server-only code can't leak into client/Trigger bundles. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UWG1YQ8KsjefFAvRV7pdtb --- .../dto/update-admin-organization.dto.ts | 6 ++++-- .../browser-automation/run-browser-automation.ts | 13 +++++++------ apps/app/src/lib/compliance.ts | 3 ++- apps/app/src/lib/org-participation.ts | 13 ++++++------- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts b/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts index beaaa75e5..7398270f3 100644 --- a/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts +++ b/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsOptional } from 'class-validator'; +import { IsBoolean, IsOptional, ValidateIf } from 'class-validator'; export class UpdateAdminOrganizationDto { @ApiPropertyOptional({ @@ -22,7 +22,9 @@ export class UpdateAdminOrganizationDto { description: "When true, the organization is platform-operated (e.g. Comp AI's own org). Platform admins are then treated as real participants — assignable, counted in compliance, and notified. Leave false for all customer orgs.", }) - @IsOptional() + // Optional, but reject an explicit `null` at validation (400) rather than + // letting it hit the non-null DB column and 500. + @ValidateIf((_, value) => value !== undefined) @IsBoolean() isInternal?: boolean; } diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.ts index b079a2279..a6140a81f 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automation.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automation.ts @@ -1,4 +1,5 @@ import { db } from '@db'; +import { orgParticipantMemberWhere } from '../../utils/org-participation'; import { logger, tags, task } from '@trigger.dev/sdk'; import { BrowserbaseService } from '../../browserbase/browserbase.service'; import { triggerEmail } from '../../email/trigger-email'; @@ -40,14 +41,14 @@ async function sendTaskStatusChangeEmails(params: { try { // Get organization, task assignee, and org owners - // Internal (platform-operated) orgs treat platform admins as real members, - // so they should also receive task notifications. Resolve before building - // the member query since the where-clause depends on it. + // Use the shared participation rule so this path stays aligned with the + // other task notifiers: internal (platform-operated) orgs include platform + // admins; other orgs exclude them (except platform admins who are owners). const organization = await db.organization.findUnique({ where: { id: organizationId }, - select: { name: true, isInternal: true }, + select: { name: true }, }); - const orgIsInternal = organization?.isInternal ?? false; + const participantWhere = await orgParticipantMemberWhere(organizationId); const [task, allMembers] = await Promise.all([ db.task.findUnique({ where: { id: taskId }, @@ -69,7 +70,7 @@ async function sendTaskStatusChangeEmails(params: { where: { organizationId, deactivated: false, - ...(orgIsInternal ? {} : { user: { role: { not: 'admin' } } }), + ...participantWhere, }, select: { role: true, diff --git a/apps/app/src/lib/compliance.ts b/apps/app/src/lib/compliance.ts index 6ce5efc98..068b1eb2a 100644 --- a/apps/app/src/lib/compliance.ts +++ b/apps/app/src/lib/compliance.ts @@ -2,7 +2,8 @@ import 'server-only'; import { db } from '@db/server'; import { BUILT_IN_ROLE_OBLIGATIONS } from '@trycompai/auth'; -import { getOrgIsInternal, isOrgParticipant } from './org-participation'; +import { getOrgIsInternal } from './org-participation'; +import { isOrgParticipant } from './org-participation-rule'; import { type UserPermissions, canAccessApp, diff --git a/apps/app/src/lib/org-participation.ts b/apps/app/src/lib/org-participation.ts index 7f9769653..cb731b063 100644 --- a/apps/app/src/lib/org-participation.ts +++ b/apps/app/src/lib/org-participation.ts @@ -1,14 +1,13 @@ import { db } from '@db/server'; -import { isOrgParticipant } from './org-participation-rule'; /** - * App-side mirror of the API's org-participation helpers. See - * `packages/auth/src/participation.ts` for the shared rule: platform admins are - * Comp AI staff embedded in customer orgs and are excluded from an org's - * business logic, UNLESS the org is internal (platform-operated, e.g. Comp AI's - * own org) where they are real members. + * App-side server helper for the org-participation rule. This module imports + * `@db/server`, so it must only be used from server code. The pure predicate + * lives in `./org-participation-rule` (dependency-free) — import + * `isOrgParticipant` from there directly so client/Trigger.dev bundles never + * pull `@db/server` in transitively. See `packages/auth/src/participation.ts` + * for the canonical rule. */ -export { isOrgParticipant }; /** Whether an org is platform-operated ("internal", e.g. Comp AI's own org). */ export async function getOrgIsInternal(organizationId: string): Promise { From ef1a53fe51a814d2c7947cc8198b0d5fb2f2f802 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 8 Jul 2026 20:55:14 -0400 Subject: [PATCH 3/6] =?UTF-8?q?fix(org):=20address=202nd=20review=20pass?= =?UTF-8?q?=20=E2=80=94=20harden=20vendor=20assignee,=20unify=20UI=20rule,?= =?UTF-8?q?=20guard=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate-vendor-mitigation: fail closed — only assign the author when the member actually exists in this org (add `author &&` guard) so a missed lookup can never write an unknown/cross-org member id. - SelectAssignee: use the shared `isOrgParticipant` predicate instead of an inline copy, so the picker can't drift from the backend rule. - lib/org-participation.ts: add `import 'server-only'` (matches compliance.ts / permissions.server.ts) so this DB-backed helper can't be pulled into a client bundle. - Add a drift guard test asserting the app-local participation rule stays identical to `@trycompai/auth/participation`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UWG1YQ8KsjefFAvRV7pdtb --- apps/app/src/components/SelectAssignee.tsx | 6 ++-- .../src/lib/org-participation-rule.test.ts | 30 +++++++++++++++++++ apps/app/src/lib/org-participation.ts | 2 ++ .../onboarding/generate-vendor-mitigation.ts | 15 ++++++---- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/SelectAssignee.tsx b/apps/app/src/components/SelectAssignee.tsx index 1c1f09f43..83206c72a 100644 --- a/apps/app/src/components/SelectAssignee.tsx +++ b/apps/app/src/components/SelectAssignee.tsx @@ -1,4 +1,5 @@ import { useOrgIsInternal } from '@/components/org-internal-context'; +import { isOrgParticipant } from '@/lib/org-participation-rule'; import { authClient } from '@/utils/auth-client'; import { Member, User } from '@db'; import { Avatar, AvatarFallback, AvatarImage } from '@trycompai/ui/avatar'; @@ -24,9 +25,10 @@ export const SelectAssignee = ({ const { data: activeMember } = authClient.useActiveMember(); const orgIsInternal = useOrgIsInternal(); // Exclude platform admins from assignee selection — except in internal - // (platform-operated) orgs, where they are real members. + // (platform-operated) orgs, where they are real members. Uses the shared + // participation rule so the UI stays in sync with the backend. const assignees = rawAssignees - .filter((a) => orgIsInternal || a.user.role !== 'admin') + .filter((a) => isOrgParticipant(a.user.role, { orgIsInternal })) .sort((a, b) => (a.user.name || a.user.email || '').localeCompare(b.user.name || b.user.email || ''), ); diff --git a/apps/app/src/lib/org-participation-rule.test.ts b/apps/app/src/lib/org-participation-rule.test.ts index 945c429c6..1bcb35d80 100644 --- a/apps/app/src/lib/org-participation-rule.test.ts +++ b/apps/app/src/lib/org-participation-rule.test.ts @@ -1,3 +1,7 @@ +import { + PLATFORM_ADMIN_ROLE as AUTH_PLATFORM_ADMIN_ROLE, + isOrgParticipant as authIsOrgParticipant, +} from '@trycompai/auth/participation'; import { describe, expect, it } from 'vitest'; import { isOrgParticipant, PLATFORM_ADMIN_ROLE } from './org-participation-rule'; @@ -26,3 +30,29 @@ describe('isOrgParticipant', () => { expect(isOrgParticipant(null, { orgIsInternal: true })).toBe(true); }); }); + +// Drift guard: this app-local rule is a deliberate dependency-free mirror of +// `@trycompai/auth/participation` (the app can't import the auth index from +// Trigger.dev-bundled files). Fail CI if the two ever diverge. +describe('org-participation rule stays in sync with @trycompai/auth', () => { + const roles: Array = [ + 'admin', + 'owner', + 'user', + 'auditor', + '', + null, + undefined, + ]; + + it('matches the canonical predicate for every role × internal flag', () => { + expect(PLATFORM_ADMIN_ROLE).toBe(AUTH_PLATFORM_ADMIN_ROLE); + for (const role of roles) { + for (const orgIsInternal of [true, false]) { + expect(isOrgParticipant(role, { orgIsInternal })).toBe( + authIsOrgParticipant(role, { orgIsInternal }), + ); + } + } + }); +}); diff --git a/apps/app/src/lib/org-participation.ts b/apps/app/src/lib/org-participation.ts index cb731b063..f205b12b6 100644 --- a/apps/app/src/lib/org-participation.ts +++ b/apps/app/src/lib/org-participation.ts @@ -1,3 +1,5 @@ +import 'server-only'; + import { db } from '@db/server'; /** diff --git a/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts b/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts index 8ee45042e..ad700b0b0 100644 --- a/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts +++ b/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts @@ -60,12 +60,17 @@ export const generateVendorMitigation = task({ select: { isInternal: true }, }), ]); + // Only assign when the author is a real member of THIS org and is a + // participant (fail closed if the lookup missed — never write an unknown + // or cross-org member id). assigneeUpdate = { - assigneeId: isOrgParticipant(author?.user.role, { - orgIsInternal: org?.isInternal ?? false, - }) - ? authorId - : null, + assigneeId: + author && + isOrgParticipant(author.user.role, { + orgIsInternal: org?.isInternal ?? false, + }) + ? authorId + : null, }; } From 77df89cdb61b038bcbc0f615f5f8a6df58657134 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 8 Jul 2026 22:03:14 -0400 Subject: [PATCH 4/6] fix(org): unify participation where-fragment, guard deactivated author, skip needless query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - orgParticipantMemberWhere: make it a faithful Prisma translation of isOrgParticipant — for non-internal orgs exclude only platform admins (include null roles; drop the owner carve-out, which the isInternal flag now supersedes). Removes the split source of truth between the predicate and the query fragment. - generate-vendor-mitigation: add `deactivated: false` to the author lookup so a deactivated member can't be auto-assigned (matches the other trigger tasks). - filterComplianceMembers (app + api): skip the `getOrgIsInternal` query when the member list has no platform admins — the flag can't change the result there. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UWG1YQ8KsjefFAvRV7pdtb --- apps/api/src/utils/compliance-filters.ts | 10 +++++++++- apps/api/src/utils/org-participation.spec.ts | 7 ++----- apps/api/src/utils/org-participation.ts | 17 ++++++++--------- apps/app/src/lib/compliance.ts | 7 +++++-- .../onboarding/generate-vendor-mitigation.ts | 2 +- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/apps/api/src/utils/compliance-filters.ts b/apps/api/src/utils/compliance-filters.ts index c352aebe0..7c864f40d 100644 --- a/apps/api/src/utils/compliance-filters.ts +++ b/apps/api/src/utils/compliance-filters.ts @@ -1,5 +1,6 @@ import { BUILT_IN_ROLE_OBLIGATIONS, + PLATFORM_ADMIN_ROLE, type RoleObligations, allRoles, isOrgParticipant, @@ -44,7 +45,14 @@ export async function filterComplianceMembers( ): Promise { if (members.length === 0) return []; - const orgIsInternal = await getOrgIsInternal(organizationId); + // The internal flag only changes a platform admin's participation, so skip the + // extra query entirely when the list has no platform admins (the common case). + const hasPlatformAdmin = members.some( + (m) => m.user?.role === PLATFORM_ADMIN_ROLE, + ); + const orgIsInternal = hasPlatformAdmin + ? await getOrgIsInternal(organizationId) + : false; // Collect all custom role names const allCustomRoleNames = new Set(); diff --git a/apps/api/src/utils/org-participation.spec.ts b/apps/api/src/utils/org-participation.spec.ts index 32d11d3d4..a1a6d4cce 100644 --- a/apps/api/src/utils/org-participation.spec.ts +++ b/apps/api/src/utils/org-participation.spec.ts @@ -60,13 +60,10 @@ describe('org-participation', () => { await expect(orgParticipantMemberWhere('org_1')).resolves.toEqual({}); }); - it('excludes platform admins but keeps owner-admins for customer orgs', async () => { + it('excludes only platform admins (incl. null roles) for customer orgs', async () => { orgFindUnique.mockResolvedValue({ isInternal: false }); await expect(orgParticipantMemberWhere('org_1')).resolves.toEqual({ - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + user: { OR: [{ role: { not: 'admin' } }, { role: null }] }, }); }); }); diff --git a/apps/api/src/utils/org-participation.ts b/apps/api/src/utils/org-participation.ts index d8183a514..cc8de545e 100644 --- a/apps/api/src/utils/org-participation.ts +++ b/apps/api/src/utils/org-participation.ts @@ -35,20 +35,19 @@ export async function isMemberOrgParticipant( } /** - * A Prisma `Member` where-fragment that keeps only org participants — i.e. - * everyone except platform admins, but still including platform admins who are - * org owners (they opted into this org). Spread it into a member query's - * `where`. Returns an empty fragment for internal orgs, so platform admins are - * included there. Use for recipient/participant lists (notifications, devices). + * A Prisma `Member` where-fragment that keeps only org participants — the SQL + * translation of {@link isOrgParticipant}. Spread it into a member query's + * `where`. Returns an empty fragment for internal orgs (everyone participates). + * For other orgs it excludes only platform admins; `role: { not }` skips NULL in + * SQL, so null roles are included explicitly to match the predicate (a null + * global role is a normal member, not a platform admin). Use for + * recipient/participant lists (notifications, mentions, devices). */ export async function orgParticipantMemberWhere( organizationId: string, ): Promise { if (await getOrgIsInternal(organizationId)) return {}; return { - OR: [ - { user: { role: { not: PLATFORM_ADMIN_ROLE } } }, - { role: { contains: 'owner' } }, - ], + user: { OR: [{ role: { not: PLATFORM_ADMIN_ROLE } }, { role: null }] }, }; } diff --git a/apps/app/src/lib/compliance.ts b/apps/app/src/lib/compliance.ts index 068b1eb2a..ddc41a422 100644 --- a/apps/app/src/lib/compliance.ts +++ b/apps/app/src/lib/compliance.ts @@ -3,7 +3,7 @@ import 'server-only'; import { db } from '@db/server'; import { BUILT_IN_ROLE_OBLIGATIONS } from '@trycompai/auth'; import { getOrgIsInternal } from './org-participation'; -import { isOrgParticipant } from './org-participation-rule'; +import { PLATFORM_ADMIN_ROLE, isOrgParticipant } from './org-participation-rule'; import { type UserPermissions, canAccessApp, @@ -81,7 +81,10 @@ export async function filterComplianceMembers( ): Promise { if (members.length === 0) return []; - const orgIsInternal = await getOrgIsInternal(organizationId); + // The internal flag only changes a platform admin's participation, so skip the + // extra query entirely when the list has no platform admins (the common case). + const hasPlatformAdmin = members.some((m) => m.user?.role === PLATFORM_ADMIN_ROLE); + const orgIsInternal = hasPlatformAdmin ? await getOrgIsInternal(organizationId) : false; const builtInRoleNames = new Set(Object.keys(BUILT_IN_ROLE_OBLIGATIONS)); const allRoleNames = new Set(); diff --git a/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts b/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts index ad700b0b0..6e8d60af1 100644 --- a/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts +++ b/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts @@ -52,7 +52,7 @@ export const generateVendorMitigation = task({ if (authorId) { const [author, org] = await Promise.all([ db.member.findFirst({ - where: { id: authorId, organizationId }, + where: { id: authorId, organizationId, deactivated: false }, include: { user: { select: { role: true } } }, }), db.organization.findUnique({ From 26cecbe4c15f0dcf0e5b5e0b24f631408a475980 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 9 Jul 2026 11:27:27 -0400 Subject: [PATCH 5/6] =?UTF-8?q?fix(org):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?spread-safe=20participant=20filter,=20confirm=20internal=20togg?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - org-participation: return the participant where-fragment wrapped in `AND` (via new `orgParticipantMemberWhereForFlag`) so spreading it no longer overwrites a caller's existing `user` filter. The two mention notifiers constrain `user: { id: { in } }`, which the bare `user` key clobbered, so notifications reached every non-admin member instead of the mentioned ones. Adds a spread-safety unit test. - OrganizationDetail: refresh the server layout (router.refresh) after toggling isInternal for the current org so OrgInternalProvider isn't stale, and require a confirmation dialog before changing this org-wide setting. - run-browser-automation: select `isInternal` in the existing org query and use the pure where-builder, dropping the redundant getOrgIsInternal query. - Normalize participation onto isOrgParticipant everywhere: drop the per-member owner carve-out from weekly-task-reminder, task-schedule, policy-schedule and the task-item assignment notifier (matches the API where-fragment and the predicate); remove now-dead role selections and stale comments. - tests: add a PointerEvent shim to the vitest setup (jsdom lacks it, base-ui Switch needs it), fix admin-org test mocks missing the new isInternal field, and cover the internal-toggle confirmation flow. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XzEVwhhoFghng6SDMGecMq --- .../comment-mention-notifier.service.ts | 6 +- .../task-item-assignment-notifier.service.ts | 14 ++-- .../task-item-mention-notifier.service.ts | 6 +- .../run-browser-automation.ts | 11 +-- apps/api/src/utils/org-participation.spec.ts | 30 +++++++- apps/api/src/utils/org-participation.ts | 41 ++++++++--- .../components/AdminOrgTabs.test.tsx | 1 + .../components/OrganizationDetail.test.tsx | 62 +++++++++++++++++ .../components/OrganizationDetail.tsx | 68 ++++++++++++++++++- apps/app/src/test-utils/setup.ts | 18 +++++ .../src/trigger/tasks/task/policy-schedule.ts | 12 +--- .../src/trigger/tasks/task/task-schedule.ts | 12 +--- .../tasks/task/weekly-task-reminder.ts | 12 +--- 13 files changed, 233 insertions(+), 60 deletions(-) diff --git a/apps/api/src/comments/comment-mention-notifier.service.ts b/apps/api/src/comments/comment-mention-notifier.service.ts index 69d6a4792..8f432f662 100644 --- a/apps/api/src/comments/comment-mention-notifier.service.ts +++ b/apps/api/src/comments/comment-mention-notifier.service.ts @@ -262,8 +262,10 @@ export class CommentMentionNotifierService { return; } - // Get mentioned users: exclude platform admins unless they are an owner of - // this org (or the org is internal, where platform admins are real members) + // Get mentioned users: exclude platform admins unless the org is internal + // (where platform admins are real members). `orgParticipantMemberWhere` + // returns an AND-wrapped fragment so it intersects with the mentioned-user + // filter above rather than overwriting it. const participantWhere = await orgParticipantMemberWhere(organizationId); const mentionedMembers = await db.member.findMany({ where: { diff --git a/apps/api/src/task-management/task-item-assignment-notifier.service.ts b/apps/api/src/task-management/task-item-assignment-notifier.service.ts index ac8cb04a5..5fe78653b 100644 --- a/apps/api/src/task-management/task-item-assignment-notifier.service.ts +++ b/apps/api/src/task-management/task-item-assignment-notifier.service.ts @@ -62,7 +62,6 @@ export class TaskItemAssignmentNotifierService { where: { id: assigneeMemberId }, select: { id: true, - role: true, user: { select: { id: true, @@ -93,20 +92,15 @@ export class TaskItemAssignmentNotifierService { return; } - // Skip notifications for platform admin members unless they are an owner - // (or this is an internal org, where platform admins are real members) - const isOwner = assigneeMember.role - ?.split(',') - .map((r: string) => r.trim()) - .includes('owner'); + // Skip notifications for platform admins unless this is an internal org + // (where platform admins are real members) — the single participation rule. if ( !isOrgParticipant(assigneeUser.role, { orgIsInternal: organization?.isInternal ?? false, - }) && - !isOwner + }) ) { this.logger.log( - `Skipping assignment notification: assignee ${assigneeUser.email} is a platform admin (non-owner)`, + `Skipping assignment notification: assignee ${assigneeUser.email} is a platform admin`, ); return; } diff --git a/apps/api/src/task-management/task-item-mention-notifier.service.ts b/apps/api/src/task-management/task-item-mention-notifier.service.ts index 95b30d8cc..9dced1ae2 100644 --- a/apps/api/src/task-management/task-item-mention-notifier.service.ts +++ b/apps/api/src/task-management/task-item-mention-notifier.service.ts @@ -51,8 +51,10 @@ export class TaskItemMentionNotifierService { return; } - // Get mentioned users: exclude platform admins unless they are an owner of - // this org (or the org is internal, where platform admins are real members) + // Get mentioned users: exclude platform admins unless the org is internal + // (where platform admins are real members). `orgParticipantMemberWhere` + // returns an AND-wrapped fragment so it intersects with the mentioned-user + // filter above rather than overwriting it. const participantWhere = await orgParticipantMemberWhere(organizationId); const mentionedMembers = await db.member.findMany({ where: { diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.ts index a6140a81f..35373e040 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automation.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automation.ts @@ -1,5 +1,5 @@ import { db } from '@db'; -import { orgParticipantMemberWhere } from '../../utils/org-participation'; +import { orgParticipantMemberWhereForFlag } from '../../utils/org-participation'; import { logger, tags, task } from '@trigger.dev/sdk'; import { BrowserbaseService } from '../../browserbase/browserbase.service'; import { triggerEmail } from '../../email/trigger-email'; @@ -40,15 +40,16 @@ async function sendTaskStatusChangeEmails(params: { const { organizationId, taskId, taskTitle, oldStatus, newStatus } = params; try { - // Get organization, task assignee, and org owners // Use the shared participation rule so this path stays aligned with the // other task notifiers: internal (platform-operated) orgs include platform - // admins; other orgs exclude them (except platform admins who are owners). + // admins; other orgs exclude them. const organization = await db.organization.findUnique({ where: { id: organizationId }, - select: { name: true }, + select: { name: true, isInternal: true }, }); - const participantWhere = await orgParticipantMemberWhere(organizationId); + const participantWhere = orgParticipantMemberWhereForFlag( + organization?.isInternal ?? false, + ); const [task, allMembers] = await Promise.all([ db.task.findUnique({ where: { id: taskId }, diff --git a/apps/api/src/utils/org-participation.spec.ts b/apps/api/src/utils/org-participation.spec.ts index a1a6d4cce..aa66b9b4a 100644 --- a/apps/api/src/utils/org-participation.spec.ts +++ b/apps/api/src/utils/org-participation.spec.ts @@ -8,6 +8,7 @@ import { getOrgIsInternal, isMemberOrgParticipant, orgParticipantMemberWhere, + orgParticipantMemberWhereForFlag, } from './org-participation'; const orgFindUnique = db.organization.findUnique as jest.Mock; @@ -54,6 +55,33 @@ describe('org-participation', () => { }); }); + describe('orgParticipantMemberWhereForFlag', () => { + it('returns an empty fragment for internal orgs (no exclusion)', () => { + expect(orgParticipantMemberWhereForFlag(true)).toEqual({}); + }); + + it('excludes only platform admins (incl. null roles) for customer orgs', () => { + expect(orgParticipantMemberWhereForFlag(false)).toEqual({ + AND: [{ user: { OR: [{ role: { not: 'admin' } }, { role: null }] } }], + }); + }); + + it('is spread-safe: does not clobber a caller-supplied user filter', () => { + // Regression: the fragment used to return a bare `user` key, which + // overwrote the mention notifiers' `user: { id: { in } }` filter and + // broadcast to the whole org. `AND` must keep both conditions. + const where = { + organizationId: 'org_1', + user: { id: { in: ['u1', 'u2'] } }, + ...orgParticipantMemberWhereForFlag(false), + }; + expect(where.user).toEqual({ id: { in: ['u1', 'u2'] } }); + expect(where.AND).toEqual([ + { user: { OR: [{ role: { not: 'admin' } }, { role: null }] } }, + ]); + }); + }); + describe('orgParticipantMemberWhere', () => { it('returns an empty fragment for internal orgs (no exclusion)', async () => { orgFindUnique.mockResolvedValue({ isInternal: true }); @@ -63,7 +91,7 @@ describe('org-participation', () => { it('excludes only platform admins (incl. null roles) for customer orgs', async () => { orgFindUnique.mockResolvedValue({ isInternal: false }); await expect(orgParticipantMemberWhere('org_1')).resolves.toEqual({ - user: { OR: [{ role: { not: 'admin' } }, { role: null }] }, + AND: [{ user: { OR: [{ role: { not: 'admin' } }, { role: null }] } }], }); }); }); diff --git a/apps/api/src/utils/org-participation.ts b/apps/api/src/utils/org-participation.ts index cc8de545e..f8413eac8 100644 --- a/apps/api/src/utils/org-participation.ts +++ b/apps/api/src/utils/org-participation.ts @@ -36,18 +36,39 @@ export async function isMemberOrgParticipant( /** * A Prisma `Member` where-fragment that keeps only org participants — the SQL - * translation of {@link isOrgParticipant}. Spread it into a member query's - * `where`. Returns an empty fragment for internal orgs (everyone participates). - * For other orgs it excludes only platform admins; `role: { not }` skips NULL in - * SQL, so null roles are included explicitly to match the predicate (a null - * global role is a normal member, not a platform admin). Use for - * recipient/participant lists (notifications, mentions, devices). + * translation of {@link isOrgParticipant}, built from an already-resolved + * internal flag. Returns an empty fragment for internal orgs (everyone + * participates). For other orgs it excludes only platform admins; `role: { not }` + * skips NULL in SQL, so null roles are included explicitly to match the + * predicate (a null global role is a normal member, not a platform admin). + * + * The exclusion is wrapped in `AND` rather than a bare `user` key so this + * fragment is safe to spread into a query that already constrains `user` (e.g. + * the mention notifiers filter `user: { id: { in } }`) — a bare `user` key would + * silently overwrite the caller's, broadening the recipient set. `AND` makes + * Prisma intersect the two conditions instead. Use for recipient/participant + * lists (notifications, mentions, devices). + */ +export function orgParticipantMemberWhereForFlag( + orgIsInternal: boolean, +): Prisma.MemberWhereInput { + if (orgIsInternal) return {}; + return { + AND: [ + { user: { OR: [{ role: { not: PLATFORM_ADMIN_ROLE } }, { role: null }] } }, + ], + }; +} + +/** + * Async convenience over {@link orgParticipantMemberWhereForFlag} that resolves + * the org's internal flag first. Prefer the `-ForFlag` variant when the caller + * has already loaded the organization, to avoid a redundant query. */ export async function orgParticipantMemberWhere( organizationId: string, ): Promise { - if (await getOrgIsInternal(organizationId)) return {}; - return { - user: { OR: [{ role: { not: PLATFORM_ADMIN_ROLE } }, { role: null }] }, - }; + return orgParticipantMemberWhereForFlag( + await getOrgIsInternal(organizationId), + ); } diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx index e0cf395d6..085951d71 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx @@ -39,6 +39,7 @@ const mockOrg: AdminOrgDetail = { onboardingCompleted: true, website: 'https://test.com', backgroundCheckStepEnabled: true, + isInternal: false, members: [ { id: 'mem_1', diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.test.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.test.tsx index dbd1ade80..19b71c135 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.test.tsx @@ -21,6 +21,7 @@ const baseOrg = { onboardingCompleted: true, members: [], backgroundCheckStepEnabled: true, + isInternal: false, }; describe('OrganizationDetail — background-check toggle', () => { @@ -91,3 +92,64 @@ describe('OrganizationDetail — background-check toggle', () => { }); }); }); + +describe('OrganizationDetail — internal-organization toggle', () => { + beforeEach(() => { + patchMock.mockReset(); + patchMock.mockResolvedValue({ data: { success: true } }); + }); + + it('asks for confirmation before saving (no PATCH on the toggle click)', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole('switch', { name: /internal organization/i }), + ); + + expect(await screen.findByRole('alertdialog')).toBeInTheDocument(); + expect(patchMock).not.toHaveBeenCalled(); + }); + + it('PATCHes isInternal only after the change is confirmed', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole('switch', { name: /internal organization/i }), + ); + await user.click( + await screen.findByRole('button', { name: /mark as internal/i }), + ); + + await waitFor(() => { + expect(patchMock).toHaveBeenCalledWith('/v1/admin/organizations/org_1', { + isInternal: true, + }); + }); + }); + + it('does not PATCH when the confirmation is canceled', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole('switch', { name: /internal organization/i }), + ); + await user.click(await screen.findByRole('button', { name: /cancel/i })); + + await waitFor(() => { + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + expect(patchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx index ab39dce8b..df6780133 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx @@ -3,7 +3,22 @@ import { RecentAuditLogs } from '@/components/RecentAuditLogs'; import type { AuditLogWithRelations } from '@/hooks/use-audit-logs'; import { apiClient } from '@/lib/api-client'; -import { Badge, Section, Stack, Switch, Text } from '@trycompai/design-system'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Badge, + Section, + Stack, + Switch, + Text, +} from '@trycompai/design-system'; +import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; import useSWR from 'swr'; @@ -21,16 +36,19 @@ interface AdminOrgDetail { export function OrganizationDetail({ org, + currentOrgId, hasAccess, }: { org: AdminOrgDetail; currentOrgId: string; hasAccess: boolean; }) { + const router = useRouter(); const [bgCheckEnabled, setBgCheckEnabled] = useState(org.backgroundCheckStepEnabled); const [savingBgCheck, setSavingBgCheck] = useState(false); const [isInternal, setIsInternal] = useState(org.isInternal); const [savingInternal, setSavingInternal] = useState(false); + const [pendingInternal, setPendingInternal] = useState(null); const handleToggleBgCheck = async (next: boolean) => { const previous = bgCheckEnabled; @@ -54,8 +72,17 @@ export function OrganizationDetail({ ); }; - const handleToggleInternal = async (next: boolean) => { + // Toggling `isInternal` changes org-wide membership semantics, so confirm + // first (the switch flips only after the admin confirms). + const handleRequestToggleInternal = (next: boolean) => { + setPendingInternal(next); + }; + + const handleConfirmToggleInternal = async () => { + if (pendingInternal === null) return; + const next = pendingInternal; const previous = isInternal; + setPendingInternal(null); setIsInternal(next); setSavingInternal(true); @@ -71,6 +98,13 @@ export function OrganizationDetail({ return; } + // If this is the org the admin is currently browsing, refresh the server + // layout so OrgInternalProvider (and consumers like the assignee picker) + // reflect the new flag without a full page reload. + if (org.id === currentOrgId) { + router.refresh(); + } + toast.success( next ? 'Marked as internal — platform admins can now participate here' @@ -132,7 +166,7 @@ export function OrganizationDetail({
@@ -152,6 +186,34 @@ export function OrganizationDetail({ ) : ( )} + + { + if (!open) setPendingInternal(null); + }} + > + + + + {pendingInternal + ? 'Mark as internal organization?' + : 'Remove internal organization?'} + + + {pendingInternal + ? 'Platform admins will be treated as real members here — assignable, counted in compliance, and notified. Only enable this for Comp AI-operated orgs, never a customer organization.' + : 'Platform admins will be excluded from this organization again — removed from assignments, compliance counts, and notifications.'} + + + + Cancel + + {pendingInternal ? 'Mark as internal' : 'Remove internal'} + + + + ); } diff --git a/apps/app/src/test-utils/setup.ts b/apps/app/src/test-utils/setup.ts index db80fa28a..db4803924 100644 --- a/apps/app/src/test-utils/setup.ts +++ b/apps/app/src/test-utils/setup.ts @@ -2,6 +2,24 @@ import '@testing-library/jest-dom/vitest'; import { afterAll, afterEach, beforeAll, vi } from 'vitest'; +// jsdom does not implement PointerEvent, which base-ui components (e.g. Switch) +// dispatch on interaction. Provide a minimal shim so pointer-driven tests work +// instead of throwing "PointerEvent is not defined". +if (typeof globalThis.PointerEvent === 'undefined') { + class PointerEventShim extends MouseEvent { + readonly pointerId: number; + readonly pointerType: string; + + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 0; + this.pointerType = params.pointerType ?? ''; + } + } + + globalThis.PointerEvent = PointerEventShim as unknown as typeof PointerEvent; +} + // Stub `server-only` so modules that use the marker can still be imported // in jsdom test runs (the package throws on client environments by design). vi.mock('server-only', () => ({})); diff --git a/apps/app/src/trigger/tasks/task/policy-schedule.ts b/apps/app/src/trigger/tasks/task/policy-schedule.ts index 17a838040..8856f4a1c 100644 --- a/apps/app/src/trigger/tasks/task/policy-schedule.ts +++ b/apps/app/src/trigger/tasks/task/policy-schedule.ts @@ -37,7 +37,6 @@ export const policySchedule = schedules.task({ deactivated: false, }, select: { - role: true, user: { select: { id: true, @@ -128,7 +127,6 @@ export const policySchedule = schedules.task({ >(); const addRecipients = ( members: Array<{ - role: string; user: { id: string; email: string; @@ -138,17 +136,13 @@ export const policySchedule = schedules.task({ }>, policy: (typeof overduePolicies)[number], ) => { - // Exclude platform admins (Comp AI staff) unless they are an org owner - // or the org is internal — same rule as isOrgParticipant elsewhere. + // Exclude platform admins (Comp AI staff) unless the org is internal — + // the single participation rule (no per-member owner carve-out). const orgIsInternal = policy.organization?.isInternal ?? false; for (const entry of members) { const user = entry.user; if (!user?.email || !user.id) continue; - const isOwner = entry.role - ?.split(',') - .map((r) => r.trim()) - .includes('owner'); - if (!isOrgParticipant(user.role, { orgIsInternal }) && !isOwner) { + if (!isOrgParticipant(user.role, { orgIsInternal })) { continue; } const key = `${user.id}-${policy.id}`; diff --git a/apps/app/src/trigger/tasks/task/task-schedule.ts b/apps/app/src/trigger/tasks/task/task-schedule.ts index a80ac832b..84bf0a551 100644 --- a/apps/app/src/trigger/tasks/task/task-schedule.ts +++ b/apps/app/src/trigger/tasks/task/task-schedule.ts @@ -40,7 +40,6 @@ export const taskSchedule = schedules.task({ deactivated: false, }, select: { - role: true, user: { select: { id: true, @@ -197,7 +196,6 @@ export const taskSchedule = schedules.task({ >(); const addRecipients = ( members: Array<{ - role: string; user: { id: string; email: string; @@ -207,17 +205,13 @@ export const taskSchedule = schedules.task({ }>, task: (typeof allUpdatedTasks)[number], ) => { - // Exclude platform admins (Comp AI staff) unless they are an org owner - // or the org is internal — same rule as isOrgParticipant elsewhere. + // Exclude platform admins (Comp AI staff) unless the org is internal — + // the single participation rule (no per-member owner carve-out). const orgIsInternal = task.organization?.isInternal ?? false; for (const entry of members) { const user = entry.user; if (!user?.email || !user.id) continue; - const isOwner = entry.role - ?.split(',') - .map((r) => r.trim()) - .includes('owner'); - if (!isOrgParticipant(user.role, { orgIsInternal }) && !isOwner) { + if (!isOrgParticipant(user.role, { orgIsInternal })) { continue; } const key = `${user.id}-${task.id}`; diff --git a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts index 397b983ff..6e49fb534 100644 --- a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts +++ b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts @@ -44,7 +44,6 @@ export const weeklyTaskReminder = schedules.task({ }, select: { id: true, - role: true, user: { select: { id: true, @@ -99,17 +98,12 @@ export const weeklyTaskReminder = schedules.task({ continue; } - // Exclude platform admins (Comp AI staff) unless they are an org owner - // or the org is internal — same rule as isOrgParticipant elsewhere. - const isOwner = member.role - .split(',') - .map((r) => r.trim()) - .includes('owner'); + // Exclude platform admins (Comp AI staff) unless the org is internal — + // the single participation rule (no per-member owner carve-out). if ( !isOrgParticipant(member.user.role, { orgIsInternal: org.isInternal, - }) && - !isOwner + }) ) { continue; } From dd071b65f74ee59edb47a78660200f3771a00c71 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Jul 2026 13:30:35 -0400 Subject: [PATCH 6/6] fix(api): reject assignees that aren't members of the organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assignee guards only threw when a member was found in the org and failed the participant check. When the org-scoped `member.findFirst` returned null — the id belongs to another org or doesn't exist — the guard was skipped and the id was persisted, since assignee columns don't enforce same-org membership. This gap is pre-existing (the prior `member?.user.role === 'admin'` check had the same null hole); hardened here while the participation refactor already touches these guards. Every assignee guard now rejects a non-member with a clear 400. Covers tasks (bulk + single update), task items (create + update), policies (create), vendors, and risks. Approver guards already rejected null members. Also consolidate task-notifier's automation-failure paths, which fetched the org twice (once for isInternal via orgParticipantMemberWhere, once for name), into a single `{ name, isInternal }` lookup + orgParticipantMemberWhereForFlag. Adds a regression test for the cross-org assignee rejection. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XzEVwhhoFghng6SDMGecMq --- apps/api/src/policies/policies.service.ts | 10 ++++--- apps/api/src/risks/risks.service.ts | 10 ++++--- .../task-management.service.ts | 12 ++++++-- apps/api/src/tasks/task-notifier.service.ts | 30 +++++++++++-------- apps/api/src/tasks/tasks.service.spec.ts | 13 ++++++++ apps/api/src/tasks/tasks.service.ts | 12 ++++++-- apps/api/src/vendors/vendors.service.ts | 10 ++++--- 7 files changed, 68 insertions(+), 29 deletions(-) diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 1c264efae..eb0fdefdb 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -300,10 +300,12 @@ export class PoliciesService { where: { id: createData.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if ( - assignee && - !(await isMemberOrgParticipant(assignee.user.role, organizationId)) - ) { + if (!assignee) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } + if (!(await isMemberOrgParticipant(assignee.user.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts index 5c5fe3c9b..b237e95c0 100644 --- a/apps/api/src/risks/risks.service.ts +++ b/apps/api/src/risks/risks.service.ts @@ -40,10 +40,12 @@ export class RisksService { where: { id: assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if ( - member && - !(await isMemberOrgParticipant(member.user.role, organizationId)) - ) { + if (!member) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } + if (!(await isMemberOrgParticipant(member.user.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); diff --git a/apps/api/src/task-management/task-management.service.ts b/apps/api/src/task-management/task-management.service.ts index e981e4898..f39b96d6e 100644 --- a/apps/api/src/task-management/task-management.service.ts +++ b/apps/api/src/task-management/task-management.service.ts @@ -309,8 +309,12 @@ export class TaskManagementService { where: { id: createTaskItemDto.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); + if (!assigneeMember) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } if ( - assigneeMember && !(await isMemberOrgParticipant( assigneeMember.user.role, organizationId, @@ -524,8 +528,12 @@ export class TaskManagementService { where: { id: updateTaskItemDto.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); + if (!assigneeMember) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } if ( - assigneeMember && !(await isMemberOrgParticipant( assigneeMember.user.role, organizationId, diff --git a/apps/api/src/tasks/task-notifier.service.ts b/apps/api/src/tasks/task-notifier.service.ts index 721e83bf9..b981148e5 100644 --- a/apps/api/src/tasks/task-notifier.service.ts +++ b/apps/api/src/tasks/task-notifier.service.ts @@ -1,5 +1,5 @@ import { db } from '@db'; -import { orgParticipantMemberWhere } from '../utils/org-participation'; +import { orgParticipantMemberWhereForFlag } from '../utils/org-participation'; import { Injectable, Logger } from '@nestjs/common'; import { TaskStatus } from '@db'; import { isUserUnsubscribed } from '@trycompai/email'; @@ -1086,12 +1086,14 @@ export class TaskNotifierService { } = params; try { - const participantWhere = await orgParticipantMemberWhere(organizationId); - const [organization, task, allMembers] = await Promise.all([ - db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }), + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true, isInternal: true }, + }); + const participantWhere = orgParticipantMemberWhereForFlag( + organization?.isInternal ?? false, + ); + const [task, allMembers] = await Promise.all([ db.task.findUnique({ where: { id: taskId }, select: { @@ -1288,13 +1290,15 @@ export class TaskNotifierService { try { const taskIds = failedTasks.map((t) => t.taskId); - const participantWhere = await orgParticipantMemberWhere(organizationId); + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true, isInternal: true }, + }); + const participantWhere = orgParticipantMemberWhereForFlag( + organization?.isInternal ?? false, + ); - const [organization, tasks, allMembers] = await Promise.all([ - db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }), + const [tasks, allMembers] = await Promise.all([ db.task.findMany({ where: { id: { in: taskIds }, diff --git a/apps/api/src/tasks/tasks.service.spec.ts b/apps/api/src/tasks/tasks.service.spec.ts index 29c3b802a..4a9ec6a1e 100644 --- a/apps/api/src/tasks/tasks.service.spec.ts +++ b/apps/api/src/tasks/tasks.service.spec.ts @@ -217,4 +217,17 @@ describe('TasksService approval gating', () => { expect(where.status).toEqual({ not: 'in_review' }); }); }); + + describe('updateTasksAssignee', () => { + it('SECURITY: rejects an assignee that is not a member of the organization', async () => { + // A cross-org or non-existent member id makes the org-scoped lookup return + // null; the guard must reject it rather than persist a foreign assignee. + memberFindFirst.mockResolvedValueOnce(null); + + await expect( + service.updateTasksAssignee(ORG_ID, [TASK_ID], 'mem_other_org', USER_ID), + ).rejects.toThrow('Assignee is not a member of this organization'); + expect(taskUpdateMany).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index 771d624d3..5188d8938 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -473,8 +473,12 @@ export class TasksService { where: { id: assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); + if (!assigneeMember) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } if ( - assigneeMember && !(await isMemberOrgParticipant( assigneeMember.user.role, organizationId, @@ -682,8 +686,12 @@ export class TasksService { where: { id: updateData.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); + if (!assigneeMember) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } if ( - assigneeMember && !(await isMemberOrgParticipant( assigneeMember.user.role, organizationId, diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts index 0b2947742..cdf39e5ca 100644 --- a/apps/api/src/vendors/vendors.service.ts +++ b/apps/api/src/vendors/vendors.service.ts @@ -226,10 +226,12 @@ export class VendorsService { where: { id: assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if ( - member && - !(await isMemberOrgParticipant(member.user.role, organizationId)) - ) { + if (!member) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } + if (!(await isMemberOrgParticipant(member.user.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', );