Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export class AdminOrganizationsService {
onboardingCompleted: true,
website: true,
backgroundCheckStepEnabled: true,
isInternal: true,
members: {
where: { isActive: true, deactivated: false },
select: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -17,4 +17,14 @@ 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.",
})
// 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;
}
12 changes: 7 additions & 5 deletions apps/api/src/comments/comment-mention-notifier.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -261,16 +262,17 @@ 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 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: {
organizationId,
deactivated: false,
user: { id: { in: mentionedUserIds } },
OR: [
{ user: { role: { not: 'admin' } } },
{ role: { contains: 'owner' } },
],
...participantWhere,
Comment thread
tofikwest marked this conversation as resolved.
},
include: { user: true },
});
Expand Down
7 changes: 3 additions & 4 deletions apps/api/src/people/people-fleet.helper.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 },
});
Expand Down
59 changes: 28 additions & 31 deletions apps/api/src/policies/policies.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -301,7 +300,12 @@ export class PoliciesService {
where: { id: createData.assigneeId, organizationId },
include: { user: { select: { role: true } } },
});
if (assignee?.user.role === 'admin') {
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',
);
Expand Down Expand Up @@ -757,9 +761,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
Expand Down Expand Up @@ -1116,8 +1121,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) {
Expand Down Expand Up @@ -1180,8 +1185,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,
Expand Down Expand Up @@ -1236,12 +1241,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',
);
Expand Down Expand Up @@ -1340,8 +1345,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
Expand Down Expand Up @@ -1491,10 +1496,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 },
Expand All @@ -1511,9 +1513,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,
Expand Down Expand Up @@ -1831,10 +1831,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',
);
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/risks/risks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,7 +40,12 @@ export class RisksService {
where: { id: assigneeId, organizationId },
include: { user: { select: { role: true } } },
});
if (member?.user.role === 'admin') {
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',
);
Expand Down
12 changes: 8 additions & 4 deletions apps/api/src/soa/soa.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
},
}));
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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 () => {
Expand Down
9 changes: 5 additions & 4 deletions apps/api/src/soa/soa.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
);
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -55,13 +56,12 @@ 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 },
select: {
id: true,
role: true,
user: {
select: {
id: true,
Expand Down Expand Up @@ -92,14 +92,15 @@ export class TaskItemAssignmentNotifierService {
return;
}

// Skip notifications for platform admin members unless they are an owner
const isOwner = assigneeMember.role
?.split(',')
.map((r: string) => r.trim())
.includes('owner');
if (assigneeUser.role === 'admin' && !isOwner) {
// Skip notifications for platform admins unless this is an internal org
// (where platform admins are real members) — the single participation rule.
if (
Comment thread
tofikwest marked this conversation as resolved.
!isOrgParticipant(assigneeUser.role, {
orgIsInternal: organization?.isInternal ?? false,
})
) {
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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -50,16 +51,17 @@ 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 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: {
organizationId,
deactivated: false,
user: { id: { in: mentionedUserIds } },
OR: [
{ user: { role: { not: 'admin' } } },
{ role: { contains: 'owner' } },
],
...participantWhere,
},
include: { user: true },
});
Expand Down
Loading
Loading