diff --git a/apps/api/src/audit/audit-log.controller.spec.ts b/apps/api/src/audit/audit-log.controller.spec.ts index 877fd4c401..15bd98ba7f 100644 --- a/apps/api/src/audit/audit-log.controller.spec.ts +++ b/apps/api/src/audit/audit-log.controller.spec.ts @@ -73,7 +73,7 @@ describe('AuditLogController', () => { where: { organizationId: 'org_1' }, include: { user: { - select: { id: true, name: true, email: true, image: true }, + select: { id: true, name: true, email: true, image: true, isPlatformAdmin: true }, }, member: true, organization: true, diff --git a/apps/api/src/audit/audit-log.controller.ts b/apps/api/src/audit/audit-log.controller.ts index 150f3b0ca5..4db1274b2a 100644 --- a/apps/api/src/audit/audit-log.controller.ts +++ b/apps/api/src/audit/audit-log.controller.ts @@ -54,7 +54,7 @@ export class AuditLogController { where, include: { user: { - select: { id: true, name: true, email: true, image: true }, + select: { id: true, name: true, email: true, image: true, isPlatformAdmin: true }, }, member: true, organization: true, diff --git a/apps/api/src/people/people-fleet.helper.ts b/apps/api/src/people/people-fleet.helper.ts index 08f540038d..33009f13b1 100644 --- a/apps/api/src/people/people-fleet.helper.ts +++ b/apps/api/src/people/people-fleet.helper.ts @@ -100,7 +100,14 @@ export async function getAllEmployeeDevices( ) { try { const employees = await db.member.findMany({ - where: { organizationId, deactivated: false }, + where: { + organizationId, + deactivated: false, + OR: [ + { user: { isPlatformAdmin: false } }, + { role: { contains: 'owner' } }, + ], + }, include: { user: true }, }); diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 00d9757c64..4e9e2cbbae 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -237,6 +237,15 @@ export class PoliciesService { async create(organizationId: string, createData: CreatePolicyDto) { try { + if (createData.assigneeId) { + const assignee = await db.member.findFirst({ + where: { id: createData.assigneeId, organizationId }, + include: { user: { select: { isPlatformAdmin: true } } }, + }); + if (assignee?.user.isPlatformAdmin) { + throw new BadRequestException('Cannot assign a platform admin as assignee'); + } + } const contentValue = createData.content as Prisma.InputJsonValue[]; // Create policy with version 1 in a transaction @@ -971,6 +980,17 @@ export class PoliciesService { ); } + // Cannot assign a platform admin as approver + const approverUser = await db.user.findUnique({ + where: { id: approver.userId }, + select: { isPlatformAdmin: true }, + }); + if (approverUser?.isPlatformAdmin) { + throw new BadRequestException( + 'Cannot assign a platform admin as approver', + ); + } + await db.policy.update({ where: { id: policyId }, data: { diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts index d2bda636da..0fb6faf37d 100644 --- a/apps/api/src/risks/risks.service.ts +++ b/apps/api/src/risks/risks.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException, Logger } from '@nestjs/common'; import { db, Prisma } from '@trycompai/db'; import { CreateRiskDto } from './dto/create-risk.dto'; import { GetRisksQueryDto } from './dto/get-risks-query.dto'; @@ -25,6 +25,16 @@ export interface PaginatedRisksResult { export class RisksService { private readonly logger = new Logger(RisksService.name); + private async validateAssigneeNotPlatformAdmin(assigneeId: string, organizationId: string) { + const member = await db.member.findFirst({ + where: { id: assigneeId, organizationId }, + include: { user: { select: { isPlatformAdmin: true } } }, + }); + if (member?.user.isPlatformAdmin) { + throw new BadRequestException('Cannot assign a platform admin as assignee'); + } + } + async findAllByOrganization( organizationId: string, assignmentFilter: Prisma.RiskWhereInput = {}, @@ -130,6 +140,9 @@ export class RisksService { async create(organizationId: string, createRiskDto: CreateRiskDto) { try { + if (createRiskDto.assigneeId) { + await this.validateAssigneeNotPlatformAdmin(createRiskDto.assigneeId, organizationId); + } const risk = await db.risk.create({ data: { ...createRiskDto, @@ -159,6 +172,10 @@ export class RisksService { // First check if the risk exists in the organization await this.findById(id, organizationId); + if (updateRiskDto.assigneeId) { + await this.validateAssigneeNotPlatformAdmin(updateRiskDto.assigneeId, organizationId); + } + const updatedRisk = await db.risk.update({ where: { id }, data: updateRiskDto, diff --git a/apps/api/src/soa/soa.service.spec.ts b/apps/api/src/soa/soa.service.spec.ts index 9eb02f6401..8c448101f6 100644 --- a/apps/api/src/soa/soa.service.spec.ts +++ b/apps/api/src/soa/soa.service.spec.ts @@ -24,6 +24,7 @@ jest.mock('@db', () => ({ update: jest.fn(), }, member: { findFirst: jest.fn() }, + user: { findUnique: jest.fn() }, sOAAnswer: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() }, }, })); @@ -214,18 +215,34 @@ describe('SOAService', () => { it('throws ForbiddenException when approver is not owner/admin', async () => { (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem-1', + userId: 'user-1', role: 'employee', }); + (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ isPlatformAdmin: false }); await expect(service.submitForApproval(dto)).rejects.toThrow( ForbiddenException, ); }); + it('throws BadRequestException when approver is platform admin', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ + id: 'mem-1', + userId: 'user-1', + role: 'admin', + }); + (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ isPlatformAdmin: true }); + await expect(service.submitForApproval(dto)).rejects.toThrow( + BadRequestException, + ); + }); + it('throws NotFoundException when document not found', async () => { (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem-1', + userId: 'user-1', role: 'admin', }); + (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ isPlatformAdmin: false }); (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null); await expect(service.submitForApproval(dto)).rejects.toThrow( NotFoundException, @@ -235,8 +252,10 @@ describe('SOAService', () => { it('throws BadRequestException when already pending approval', async () => { (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem-1', + userId: 'user-1', role: 'admin', }); + (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ isPlatformAdmin: false }); (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ id: 'doc-1', status: 'needs_review', @@ -249,8 +268,10 @@ describe('SOAService', () => { it('submits for approval successfully', async () => { (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem-1', + userId: 'user-1', role: 'owner', }); + (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ isPlatformAdmin: false }); (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ id: 'doc-1', status: 'draft', diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index 07e353e118..575263ed44 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -391,6 +391,15 @@ export class SOAService { throw new NotFoundException('Approver not found in organization'); } + // Cannot assign a platform admin as approver + const approverUser = await db.user.findUnique({ + where: { id: approverMember.userId }, + select: { isPlatformAdmin: true }, + }); + if (approverUser?.isPlatformAdmin) { + throw new BadRequestException('Cannot assign a platform admin as approver'); + } + const isOwnerOrAdmin = approverMember.role.includes('owner') || approverMember.role.includes('admin'); diff --git a/apps/api/src/task-management/task-management.service.ts b/apps/api/src/task-management/task-management.service.ts index 07a9fc46f9..34d41ae623 100644 --- a/apps/api/src/task-management/task-management.service.ts +++ b/apps/api/src/task-management/task-management.service.ts @@ -265,6 +265,16 @@ export class TaskManagementService { ); } + if (createTaskItemDto.assigneeId) { + const assigneeMember = await db.member.findFirst({ + where: { id: createTaskItemDto.assigneeId, organizationId }, + include: { user: { select: { isPlatformAdmin: true } } }, + }); + if (assigneeMember?.user.isPlatformAdmin) { + throw new BadRequestException('Cannot assign a platform admin as assignee'); + } + } + const taskItem = await db.taskItem.create({ data: { ...createTaskItemDto, @@ -470,6 +480,15 @@ export class TaskManagementService { updateData.priority = updateTaskItemDto.priority; } if (updateTaskItemDto.assigneeId !== undefined) { + if (updateTaskItemDto.assigneeId) { + const assigneeMember = await db.member.findFirst({ + where: { id: updateTaskItemDto.assigneeId, organizationId }, + include: { user: { select: { isPlatformAdmin: true } } }, + }); + if (assigneeMember?.user.isPlatformAdmin) { + throw new BadRequestException('Cannot assign a platform admin as assignee'); + } + } updateData.assigneeId = updateTaskItemDto.assigneeId; } diff --git a/apps/api/src/tasks/task-notifier.service.ts b/apps/api/src/tasks/task-notifier.service.ts index 581b48935c..dfc5aeb5f1 100644 --- a/apps/api/src/tasks/task-notifier.service.ts +++ b/apps/api/src/tasks/task-notifier.service.ts @@ -1095,6 +1095,10 @@ export class TaskNotifierService { where: { organizationId, deactivated: false, + OR: [ + { user: { isPlatformAdmin: false } }, + { role: { contains: 'owner' } }, + ], }, select: { id: true, @@ -1298,6 +1302,10 @@ export class TaskNotifierService { where: { organizationId, deactivated: false, + OR: [ + { user: { isPlatformAdmin: false } }, + { role: { contains: 'owner' } }, + ], }, select: { id: true, diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index 97da5a34ca..86d5eff79a 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -204,7 +204,7 @@ export class TasksService { where, include: { user: { - select: { id: true, name: true, email: true, image: true }, + select: { id: true, name: true, email: true, image: true, isPlatformAdmin: true }, }, }, orderBy: { timestamp: 'desc' }, @@ -357,6 +357,16 @@ export class TasksService { changedByUserId: string, ): Promise<{ updatedCount: number }> { try { + if (assigneeId) { + const assigneeMember = await db.member.findFirst({ + where: { id: assigneeId, organizationId }, + include: { user: { select: { isPlatformAdmin: true } } }, + }); + if (assigneeMember?.user.isPlatformAdmin) { + throw new BadRequestException('Cannot assign a platform admin as assignee'); + } + } + const result = await db.task.updateMany({ where: { id: { @@ -493,6 +503,15 @@ export class TasksService { dataToUpdate.status = updateData.status; } if (updateData.assigneeId !== undefined) { + if (updateData.assigneeId !== null) { + const assigneeMember = await db.member.findFirst({ + where: { id: updateData.assigneeId, organizationId }, + include: { user: { select: { isPlatformAdmin: true } } }, + }); + if (assigneeMember?.user.isPlatformAdmin) { + throw new BadRequestException('Cannot assign a platform admin as assignee'); + } + } dataToUpdate.assigneeId = updateData.assigneeId === null ? null : updateData.assigneeId; } @@ -826,6 +845,10 @@ export class TasksService { throw new BadRequestException('Approver not found or is deactivated'); } + if (approver.user.isPlatformAdmin) { + throw new BadRequestException('Cannot assign a platform admin as approver'); + } + const currentMember = await db.member.findFirst({ where: { userId, organizationId, deactivated: false }, }); @@ -899,6 +922,10 @@ export class TasksService { throw new BadRequestException('Approver not found or is deactivated'); } + if (approver.user.isPlatformAdmin) { + throw new BadRequestException('Cannot assign a platform admin as approver'); + } + const tasks = await db.task.findMany({ where: { id: { in: taskIds }, diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts index 9167c60d1e..6281493e9f 100644 --- a/apps/api/src/vendors/vendors.service.ts +++ b/apps/api/src/vendors/vendors.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException, Logger } from '@nestjs/common'; import { db, TaskItemPriority, TaskItemStatus } from '@trycompai/db'; import { CreateVendorDto } from './dto/create-vendor.dto'; import { UpdateVendorDto } from './dto/update-vendor.dto'; @@ -198,12 +198,25 @@ export class VendorsService { } } + private async validateAssigneeNotPlatformAdmin(assigneeId: string, organizationId: string) { + const member = await db.member.findFirst({ + where: { id: assigneeId, organizationId }, + include: { user: { select: { isPlatformAdmin: true } } }, + }); + if (member?.user.isPlatformAdmin) { + throw new BadRequestException('Cannot assign a platform admin as assignee'); + } + } + async create( organizationId: string, createVendorDto: CreateVendorDto, createdByUserId?: string, ) { try { + if (createVendorDto.assigneeId) { + await this.validateAssigneeNotPlatformAdmin(createVendorDto.assigneeId, organizationId); + } const vendor = await db.vendor.create({ data: { ...createVendorDto, @@ -587,6 +600,10 @@ export class VendorsService { // First check if the vendor exists in the organization await this.findById(id, organizationId); + if (updateVendorDto.assigneeId) { + await this.validateAssigneeNotPlatformAdmin(updateVendorDto.assigneeId, organizationId); + } + const updatedVendor = await db.vendor.update({ where: { id }, data: updateVendorDto, diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index 2aa3a8f9ca..83f6ffbf0b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -121,6 +121,7 @@ export function MemberRow({ : 0; const isOwner = currentRoles.includes('owner'); + const isPlatformAdmin = member.user.isPlatformAdmin; const canRemove = !isOwner; const isDeactivated = member.deactivated || !member.isActive; const profileHref = `/${orgId}/people/${memberId}`; @@ -219,6 +220,9 @@ export function MemberRow({
+ {member.user.isPlatformAdmin && ( + Comp AI + )} {currentRoles.map((role) => ( {getRoleLabel(role)} @@ -333,6 +337,14 @@ export function MemberRow({
+ {isPlatformAdmin && ( +
+ Comp AI + + This role is managed by the platform and cannot be removed. + +
+ )}
{userName} + {user?.isPlatformAdmin && ( + Comp AI + )}
{userName} + {user?.isPlatformAdmin && ( + Comp AI + )} | null; + user: Pick | null; }; interface ActivityResponse { diff --git a/apps/app/src/components/RecentAuditLogs.tsx b/apps/app/src/components/RecentAuditLogs.tsx index 9e0209b9bc..fa130f5e17 100644 --- a/apps/app/src/components/RecentAuditLogs.tsx +++ b/apps/app/src/components/RecentAuditLogs.tsx @@ -3,6 +3,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar'; import type { AuditLog } from '@db'; import { + Badge, Button, HStack, Section, @@ -83,6 +84,11 @@ function LogRow({ log }: { log: AuditLogWithRelations }) { {userName} + {log.user?.isPlatformAdmin && ( + <> + {' '}Comp AI + + )} {' '} {log.description || 'made a change'} {changeCount > 0 && ( diff --git a/apps/app/src/components/SelectAssignee.tsx b/apps/app/src/components/SelectAssignee.tsx index b82c0f18dd..21bc7fc98c 100644 --- a/apps/app/src/components/SelectAssignee.tsx +++ b/apps/app/src/components/SelectAssignee.tsx @@ -16,11 +16,13 @@ interface SelectAssigneeProps { export const SelectAssignee = ({ assigneeId, disabled, - assignees, + assignees: rawAssignees, onAssigneeChange, withTitle = true, }: SelectAssigneeProps) => { const { data: activeMember } = authClient.useActiveMember(); + // Exclude platform admins from assignee selection + const assignees = rawAssignees.filter((a) => !a.user.isPlatformAdmin); const [selectedAssignee, setSelectedAssignee] = useState<(Member & { user: User }) | null>(null); // Initialize selectedAssignee based on assigneeId prop