diff --git a/apps/api/src/devices/devices.controller.spec.ts b/apps/api/src/devices/devices.controller.spec.ts index 048605c62c..f14a3c2dff 100644 --- a/apps/api/src/devices/devices.controller.spec.ts +++ b/apps/api/src/devices/devices.controller.spec.ts @@ -38,6 +38,7 @@ describe('DevicesController', () => { findAllByOrganization: jest.fn(), findAllByMember: jest.fn(), getMemberById: jest.fn(), + removeDeviceById: jest.fn(), }; const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; @@ -202,4 +203,28 @@ describe('DevicesController', () => { ).rejects.toThrow('FleetDM unavailable'); }); }); + + describe('deleteDevice', () => { + it('should call service removeDeviceById with org, device, and user', async () => { + mockService.removeDeviceById.mockResolvedValue(undefined); + + await controller.deleteDevice('dev_1', 'org_1', mockAuthContext); + + expect(service.removeDeviceById).toHaveBeenCalledWith({ + organizationId: 'org_1', + deviceId: 'dev_1', + userId: 'usr_1', + }); + }); + + it('should propagate service errors', async () => { + mockService.removeDeviceById.mockRejectedValue( + new Error('Only organization owners can remove devices'), + ); + + await expect( + controller.deleteDevice('dev_1', 'org_1', mockAuthContext), + ).rejects.toThrow('Only organization owners can remove devices'); + }); + }); }); diff --git a/apps/api/src/devices/devices.controller.ts b/apps/api/src/devices/devices.controller.ts index f0f9fb8ab6..001a4e6c15 100644 --- a/apps/api/src/devices/devices.controller.ts +++ b/apps/api/src/devices/devices.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { Controller, Delete, Get, HttpCode, Param, UseGuards } from '@nestjs/common'; import { ApiOperation, ApiParam, @@ -220,4 +220,41 @@ export class DevicesController { }), }; } + + @Delete(':id') + @RequirePermission('member', 'delete') + @HttpCode(204) + @ApiOperation({ + summary: 'Delete device', + description: + 'Deletes a single device in the authenticated organization. Only organization owners can delete devices.', + }) + @ApiParam({ + name: 'id', + description: 'Device ID to delete', + example: 'dev_abc123def456', + }) + @ApiResponse({ + status: 204, + description: 'Device deleted successfully', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - only organization owners can delete devices', + }) + @ApiResponse({ + status: 404, + description: 'Organization or device not found', + }) + async deleteDevice( + @Param('id') id: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ): Promise { + await this.devicesService.removeDeviceById({ + organizationId, + deviceId: id, + userId: authContext.userId, + }); + } } diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts new file mode 100644 index 0000000000..b03ceb90dd --- /dev/null +++ b/apps/api/src/devices/devices.service.spec.ts @@ -0,0 +1,134 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { FleetService } from '../lib/fleet.service'; +import { DevicesService } from './devices.service'; + +jest.mock('@db', () => ({ + db: { + organization: { findUnique: jest.fn() }, + member: { findFirst: jest.fn() }, + device: { deleteMany: jest.fn() }, + }, +})); + +describe('DevicesService', () => { + let service: DevicesService; + + const mockFleetService = { + getHostsByLabel: jest.fn(), + getMultipleHosts: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DevicesService, + { provide: FleetService, useValue: mockFleetService }, + ], + }).compile(); + + service = module.get(DevicesService); + jest.clearAllMocks(); + }); + + describe('removeDeviceById', () => { + it('throws when organization does not exist', async () => { + (db.organization.findUnique as jest.Mock).mockResolvedValue(null); + + await expect( + service.removeDeviceById({ + organizationId: 'org_missing', + deviceId: 'dev_1', + userId: 'usr_1', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('throws when user id is missing', async () => { + (db.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + }); + + await expect( + service.removeDeviceById({ + organizationId: 'org_1', + deviceId: 'dev_1', + }), + ).rejects.toThrow(ForbiddenException); + }); + + it('throws when user is not a member of organization', async () => { + (db.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + }); + (db.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.removeDeviceById({ + organizationId: 'org_1', + deviceId: 'dev_1', + userId: 'usr_1', + }), + ).rejects.toThrow('User is not a member of this organization'); + }); + + it('throws when member is not an owner', async () => { + (db.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + }); + (db.member.findFirst as jest.Mock).mockResolvedValue({ + role: 'admin', + }); + + await expect( + service.removeDeviceById({ + organizationId: 'org_1', + deviceId: 'dev_1', + userId: 'usr_1', + }), + ).rejects.toThrow('Only organization owners can remove devices'); + }); + + it('throws when device does not exist in organization', async () => { + (db.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + }); + (db.member.findFirst as jest.Mock).mockResolvedValue({ + role: 'owner', + }); + (db.device.deleteMany as jest.Mock).mockResolvedValue({ count: 0 }); + + await expect( + service.removeDeviceById({ + organizationId: 'org_1', + deviceId: 'dev_missing', + userId: 'usr_1', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('deletes device when caller is owner', async () => { + (db.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + }); + (db.member.findFirst as jest.Mock).mockResolvedValue({ + role: ' employee , owner ', + }); + (db.device.deleteMany as jest.Mock).mockResolvedValue({ count: 1 }); + + await service.removeDeviceById({ + organizationId: 'org_1', + deviceId: 'dev_1', + userId: 'usr_1', + }); + + expect(db.device.deleteMany).toHaveBeenCalledWith({ + where: { + id: 'dev_1', + organizationId: 'org_1', + }, + }); + }); + }); +}); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index cc43686d84..fc65217a9b 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -1,4 +1,9 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + Logger, + ForbiddenException, +} from '@nestjs/common'; import { db } from '@db'; import { getDeviceComplianceStatus } from '@trycompai/utils/devices'; import { FleetService } from '../lib/fleet.service'; @@ -175,6 +180,66 @@ export class DevicesService { } } + async removeDeviceById({ + organizationId, + deviceId, + userId, + }: { + organizationId: string; + deviceId: string; + userId?: string; + }): Promise { + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { id: true }, + }); + + if (!organization) { + throw new NotFoundException( + `Organization with ID ${organizationId} not found`, + ); + } + + if (!userId) { + throw new ForbiddenException('Only organization owners can remove devices'); + } + + const member = await db.member.findFirst({ + where: { + userId, + organizationId, + deactivated: false, + }, + select: { role: true }, + }); + + if (!member) { + throw new ForbiddenException('User is not a member of this organization'); + } + + const memberRoles = member.role + .split(',') + .map((role) => role.trim().toLowerCase()); + const isOwner = memberRoles.includes('owner'); + + if (!isOwner) { + throw new ForbiddenException('Only organization owners can remove devices'); + } + + const deleteResult = await db.device.deleteMany({ + where: { + id: deviceId, + organizationId, + }, + }); + + if (deleteResult.count === 0) { + throw new NotFoundException( + `Device with ID ${deviceId} not found in organization ${organizationId}`, + ); + } + } + // --- Private helpers --- private async getFleetDevicesForOrg( diff --git a/apps/api/src/email/dto/send-batch-email.dto.ts b/apps/api/src/email/dto/send-batch-email.dto.ts new file mode 100644 index 0000000000..f73ae12769 --- /dev/null +++ b/apps/api/src/email/dto/send-batch-email.dto.ts @@ -0,0 +1,45 @@ +import { + IsString, + IsEmail, + IsOptional, + IsArray, + ValidateNested, + ArrayMinSize, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +class BatchEmailItemDto { + @ApiProperty({ description: 'Recipient email address' }) + @IsEmail() + to: string; + + @ApiProperty({ description: 'Email subject line' }) + @IsString() + subject: string; + + @ApiProperty({ description: 'Pre-rendered HTML content' }) + @IsString() + html: string; + + @ApiPropertyOptional({ description: 'Explicit FROM address override' }) + @IsOptional() + @IsString() + from?: string; + + @ApiPropertyOptional({ description: 'CC recipients' }) + @IsOptional() + cc?: string | string[]; +} + +export class SendBatchEmailDto { + @ApiProperty({ + description: 'Array of emails to send', + type: [BatchEmailItemDto], + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => BatchEmailItemDto) + emails: BatchEmailItemDto[]; +} diff --git a/apps/api/src/email/email.controller.ts b/apps/api/src/email/email.controller.ts index fd62a77649..7acb18aa71 100644 --- a/apps/api/src/email/email.controller.ts +++ b/apps/api/src/email/email.controller.ts @@ -11,7 +11,9 @@ import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; import { RequirePermission } from '../auth/require-permission.decorator'; import { SendEmailDto } from './dto/send-email.dto'; +import { SendBatchEmailDto } from './dto/send-batch-email.dto'; import type { sendEmailTask } from '../trigger/email/send-email'; +import type { sendBatchEmailTask } from '../trigger/email/send-batch-email'; @ApiExcludeController() @ApiTags('Internal - Email') @@ -43,4 +45,31 @@ export class EmailController { return { success: true, taskId: handle.id }; } + + @Post('send-batch') + @HttpCode(200) + @RequirePermission('email', 'send') + @ApiOperation({ + summary: 'Send a batch of emails via the centralized Trigger task (internal)', + }) + @ApiResponse({ status: 200, description: 'Batch email task triggered' }) + async sendBatchEmail(@Body() dto: SendBatchEmailDto) { + const fromAddress = + process.env.RESEND_FROM_SYSTEM ?? process.env.RESEND_FROM_DEFAULT; + + const emails = dto.emails.map((email) => ({ + to: email.to, + subject: email.subject, + html: email.html, + from: email.from ?? fromAddress, + cc: email.cc, + })); + + const handle = await tasks.trigger( + 'send-batch-email', + { emails }, + ); + + return { success: true, taskId: handle.id }; + } } diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 3708b0323c..0ef62a03cc 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -265,6 +265,12 @@ export class TasksController { example: '2025-01-01T00:00:00.000Z', description: 'Optional review date to set on all tasks', }, + notRelevantJustification: { + type: 'string', + example: 'This control is out of scope for our SOC 2 audit.', + description: + 'Required justification when marking evidence tasks as not_relevant', + }, }, required: ['taskIds', 'status'], }, @@ -291,9 +297,10 @@ export class TasksController { taskIds: string[]; status: TaskStatus; reviewDate?: string; + notRelevantJustification?: string; }, ): Promise<{ updatedCount: number }> { - const { taskIds, status, reviewDate } = body; + const { taskIds, status, reviewDate, notRelevantJustification } = body; if (!Array.isArray(taskIds) || taskIds.length === 0) { throw new BadRequestException('taskIds must be a non-empty array'); @@ -326,6 +333,7 @@ export class TasksController { status, parsedReviewDate, userId, + notRelevantJustification, ); } @@ -811,6 +819,12 @@ export class TasksController { format: 'date-time', example: '2025-01-01T00:00:00.000Z', }, + notRelevantJustification: { + type: 'string', + example: 'This control is out of scope for our SOC 2 audit.', + description: + 'Required justification when marking evidence tasks as not_relevant', + }, }, }, }) @@ -846,6 +860,7 @@ export class TasksController { integrationScheduleFrequency?: string; department?: string; reviewDate?: string; + notRelevantJustification?: string; }, ): Promise { const userId = await this.resolveTaskMutationUserId( @@ -884,6 +899,7 @@ export class TasksController { | undefined, department: body.department, reviewDate: parsedReviewDate, + notRelevantJustification: body.notRelevantJustification, }, userId, ); diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index 5a3e57d00c..44c6b01211 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -372,6 +372,7 @@ export class TasksService { status: TaskStatus, reviewDate: Date | undefined, changedByUserId: string, + notRelevantJustification?: string, ): Promise<{ updatedCount: number }> { try { // Enforce approval workflow: exclude tasks that can't be bulk-updated @@ -387,11 +388,17 @@ export class TasksService { where.approverId = null; } + const justificationData = + status === TaskStatus.not_relevant + ? { notRelevantJustification: notRelevantJustification ?? null } + : { notRelevantJustification: null }; + const result = await db.task.updateMany({ where, data: { status, updatedAt: new Date(), + ...justificationData, ...(reviewDate !== undefined ? { reviewDate } : {}), }, }); @@ -561,6 +568,7 @@ export class TasksService { integrationScheduleFrequency?: TaskFrequency; department?: string; reviewDate?: Date | null; + notRelevantJustification?: string; }, changedByUserId: string, ): Promise { @@ -596,6 +604,7 @@ export class TasksService { integrationScheduleFrequency?: TaskFrequency; department?: string; reviewDate?: Date | null; + notRelevantJustification?: string | null; } = {}; if (updateData.title !== undefined) { @@ -626,6 +635,13 @@ export class TasksService { ); } dataToUpdate.status = updateData.status; + + if (updateData.status === TaskStatus.not_relevant) { + dataToUpdate.notRelevantJustification = + updateData.notRelevantJustification ?? null; + } else { + dataToUpdate.notRelevantJustification = null; + } } if (updateData.assigneeId !== undefined) { if (updateData.assigneeId !== null) { @@ -712,6 +728,11 @@ export class TasksService { field: 'status', oldValue: existingTask.status, newValue: updateData.status, + ...(updateData.status === TaskStatus.not_relevant && + updateData.notRelevantJustification && { + notRelevantJustification: + updateData.notRelevantJustification, + }), }, }, }); diff --git a/apps/api/src/trigger/email/send-batch-email.ts b/apps/api/src/trigger/email/send-batch-email.ts new file mode 100644 index 0000000000..e095e5a2ca --- /dev/null +++ b/apps/api/src/trigger/email/send-batch-email.ts @@ -0,0 +1,108 @@ +import { logger, queue, schemaTask } from '@trigger.dev/sdk'; +import { z } from 'zod'; +import { resend } from '../../email/resend'; +import { generateUnsubscribeToken } from '@trycompai/email'; + +const RESEND_BATCH_LIMIT = 100; + +const batchEmailQueue = queue({ + name: 'send-batch-email', + concurrencyLimit: 5, +}); + +const batchEmailItemSchema = z.object({ + to: z.string(), + subject: z.string(), + html: z.string(), + from: z.string().optional(), + cc: z.union([z.string(), z.array(z.string())]).optional(), +}); + +export const sendBatchEmailTask = schemaTask({ + id: 'send-batch-email', + queue: batchEmailQueue, + retry: { + maxAttempts: 3, + }, + schema: z.object({ + emails: z.array(batchEmailItemSchema).min(1), + }), + run: async (params) => { + if (!resend) { + logger.error('Resend not initialized - missing RESEND_API_KEY'); + throw new Error('Resend not initialized - missing API key'); + } + + const fromDefault = + process.env.RESEND_FROM_SYSTEM ?? process.env.RESEND_FROM_DEFAULT; + + if (!fromDefault) { + throw new Error('Missing FROM address in environment variables'); + } + + const toTest = process.env.RESEND_TO_TEST; + const apiBaseUrl = + process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; + + let totalSent = 0; + let totalFailed = 0; + + for (let i = 0; i < params.emails.length; i += RESEND_BATCH_LIMIT) { + const chunk = params.emails.slice(i, i + RESEND_BATCH_LIMIT); + + const payload = chunk.map((email) => { + const token = generateUnsubscribeToken(email.to); + const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(email.to)}&token=${encodeURIComponent(token)}`; + + return { + from: email.from ?? fromDefault, + to: toTest ?? email.to, + cc: email.cc, + subject: email.subject, + html: email.html, + headers: { + 'List-Unsubscribe': `<${oneClickUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + }; + }); + + const { data, error } = await resend.batch.send(payload, { + batchValidation: 'permissive', + }); + + if (error) { + logger.error('Resend batch API error', { + error, + chunkIndex: i, + chunkSize: chunk.length, + }); + totalFailed += chunk.length; + continue; + } + + const sent = data?.data?.length ?? 0; + totalSent += sent; + + if ('errors' in data && Array.isArray(data.errors)) { + for (const err of data.errors) { + logger.warn('Batch email failed for recipient', { + index: err.index, + message: err.message, + to: chunk[err.index]?.to, + }); + totalFailed += 1; + } + } + + logger.info('Batch chunk sent', { + chunkIndex: i, + chunkSize: chunk.length, + sent, + }); + } + + logger.info('Batch email task complete', { totalSent, totalFailed }); + return { totalSent, totalFailed }; + }, +}); diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index d2c3df5031..407014849c 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -1956,6 +1956,8 @@ export class TrustAccessService { | 'pci_dss' | 'nen7510' | 'iso9001' + | 'pipeda' + | 'ccpa' > = { [TrustFramework.iso_27001]: 'iso27001', [TrustFramework.iso_42001]: 'iso42001', @@ -1967,6 +1969,8 @@ export class TrustAccessService { [TrustFramework.pci_dss]: 'pci_dss', [TrustFramework.nen_7510]: 'nen7510', [TrustFramework.iso_9001]: 'iso9001', + [TrustFramework.pipeda]: 'pipeda', + [TrustFramework.ccpa]: 'ccpa', }; const enabledField = frameworkFieldMap[framework]; @@ -2741,6 +2745,8 @@ export class TrustAccessService { 'nen 7510': 'nen7510', iso9001: 'iso9001', 'iso 9001': 'iso9001', + pipeda: 'pipeda', + ccpa: 'ccpa', }; const badges: Array<{ type: string; verified: boolean }> = []; @@ -2789,6 +2795,7 @@ export class TrustAccessService { hipaa: 'HIPAA', pci_dss: 'PCI DSS', nen7510: 'NEN 7510', + pipeda: 'PIPEDA', ccpa: 'CCPA', }; diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index 4d5e0d4fde..f15e1a0dca 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -128,7 +128,9 @@ export class TrustPortalService { | 'soc2type2_status' | 'pci_dss_status' | 'nen7510_status' - | 'iso9001_status'; + | 'iso9001_status' + | 'pipeda_status' + | 'ccpa_status'; enabledField: | 'iso27001' | 'iso42001' @@ -139,7 +141,9 @@ export class TrustPortalService { | 'soc2type2' | 'pci_dss' | 'nen7510' - | 'iso9001'; + | 'iso9001' + | 'pipeda' + | 'ccpa'; slug: string; } > = { @@ -193,6 +197,16 @@ export class TrustPortalService { enabledField: 'soc3', slug: 'soc3', }, + [TrustFramework.pipeda]: { + statusField: 'pipeda_status', + enabledField: 'pipeda', + slug: 'pipeda', + }, + [TrustFramework.ccpa]: { + statusField: 'ccpa_status', + enabledField: 'ccpa', + slug: 'ccpa', + }, }; async getDomainStatus( @@ -628,6 +642,8 @@ export class TrustPortalService { pci_dss: 'pci_dss', nen7510: 'nen7510', iso9001: 'iso9001', + pipeda: 'pipeda', + ccpa: 'ccpa', }; // Map framework status fields (frontend sends camelCase like "iso27001Status", DB uses "iso27001_status") @@ -642,6 +658,8 @@ export class TrustPortalService { pcidssStatus: 'pci_dss_status', nen7510Status: 'nen7510_status', iso9001Status: 'iso9001_status', + pipedaStatus: 'pipeda_status', + ccpaStatus: 'ccpa_status', // Also support snake_case input (from other callers) soc2type1_status: 'soc2type1_status', soc2type2_status: 'soc2type2_status', @@ -653,6 +671,8 @@ export class TrustPortalService { pci_dss_status: 'pci_dss_status', nen7510_status: 'nen7510_status', iso9001_status: 'iso9001_status', + pipeda_status: 'pipeda_status', + ccpa_status: 'ccpa_status', }; for (const [inputKey, dbField] of Object.entries(boolFieldMap)) { @@ -1533,6 +1553,8 @@ export class TrustPortalService { pcidss: trust.pci_dss ?? false, nen7510: trust.nen7510 ?? false, iso9001: trust.iso9001 ?? false, + pipeda: trust.pipeda ?? false, + ccpa: trust.ccpa ?? false, // Framework statuses soc2type1Status: trust.soc2type1_status ?? 'started', soc2type2Status: @@ -1547,6 +1569,8 @@ export class TrustPortalService { pcidssStatus: trust.pci_dss_status ?? 'started', nen7510Status: trust.nen7510_status ?? 'started', iso9001Status: trust.iso9001_status ?? 'started', + pipedaStatus: trust.pipeda_status ?? 'started', + ccpaStatus: trust.ccpa_status ?? 'started', // Overview overviewTitle: trust.overviewTitle ?? null, overviewContent: trust.overviewContent ?? defaultOverviewContent, diff --git a/apps/app/public/badges/ccpa.svg b/apps/app/public/badges/ccpa.svg new file mode 100644 index 0000000000..c6971f6ae2 --- /dev/null +++ b/apps/app/public/badges/ccpa.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/app/public/badges/pipeda.svg b/apps/app/public/badges/pipeda.svg new file mode 100644 index 0000000000..e256eebf79 --- /dev/null +++ b/apps/app/public/badges/pipeda.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksTable.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksTable.tsx index b35763256a..10fe350177 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksTable.tsx @@ -48,6 +48,8 @@ const FRAMEWORK_BADGES: Record = { 'NEN 7510': '/badges/nen7510.svg', 'ISO 9001': '/badges/iso9001.svg', 'SOC 2 Type 1': '/badges/soc2.svg', + 'CCPA': '/badges/ccpa.svg', + 'PIPEDA': '/badges/pipeda.svg', }; function getFrameworkBadge(name: string): string | null { diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FrameworksOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FrameworksOverview.tsx index ea6e1436eb..b716a6f111 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/FrameworksOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/FrameworksOverview.tsx @@ -62,10 +62,19 @@ export function mapFrameworkToBadge(framework: FrameworkInstanceWithControls) { if (frameworkName === 'ISO 9001') { return '/badges/iso9001.svg'; } + if (frameworkName === 'SOC 2 Type 1') { return '/badges/soc2.svg'; } + if (frameworkName === 'CCPA') { + return '/badges/ccpa.svg'; + } + + if (frameworkName === 'PIPEDA') { + return '/badges/pipeda.svg'; + } + return null; } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx index 50ccdf1ced..d290610fc5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx @@ -3,6 +3,10 @@ import { Badge, Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, Empty, EmptyDescription, EmptyHeader, @@ -19,11 +23,20 @@ import { TableRow, Text, } from '@trycompai/design-system'; -import { Download, Information, Search } from '@trycompai/design-system/icons'; +import { + Download, + Information, + OverflowMenuVertical, + Search, + TrashCan, +} from '@trycompai/design-system/icons'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { useSWRConfig } from 'swr'; +import { usePeopleActions } from '@/hooks/use-people-api'; import type { DeviceWithChecks } from '../types'; import { buildDevicesCsv, @@ -31,9 +44,11 @@ import { downloadDevicesCsv, } from '../lib/devices-csv'; import { DeviceDetails } from './DeviceDetails'; +import { RemoveDeviceAlert } from '../../all/components/RemoveDeviceAlert'; export interface DeviceAgentDevicesListProps { devices: DeviceWithChecks[]; + isCurrentUserOwner: boolean; } const CHECK_FIELDS = [ @@ -159,12 +174,20 @@ function CheckBadges({ device }: { device: DeviceWithChecks }) { ); } -export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps) => { +export const DeviceAgentDevicesList = ({ + devices, + isCurrentUserOwner, +}: DeviceAgentDevicesListProps) => { const { orgId } = useParams<{ orgId: string }>(); + const { removeDeviceAgent } = usePeopleActions(); + const { mutate } = useSWRConfig(); const [selectedDevice, setSelectedDevice] = useState(null); + const [actionDevice, setActionDevice] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(50); + const [isRemoveDeviceAlertOpen, setIsRemoveDeviceAlertOpen] = useState(false); + const [isRemovingDevice, setIsRemovingDevice] = useState(false); const filteredDevices = useMemo(() => { if (!searchQuery) return devices; @@ -190,6 +213,32 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps) downloadDevicesCsv(filename, contents); } + async function handleRemoveDevice() { + if (!actionDevice) return; + setIsRemovingDevice(true); + try { + await removeDeviceAgent(actionDevice.id); + await mutate( + ['people-agent-devices', orgId], + (currentDevices: DeviceWithChecks[] | undefined) => + Array.isArray(currentDevices) + ? currentDevices.filter((device) => device.id !== actionDevice.id) + : currentDevices, + false, + ); + toast.success('Device removed successfully'); + if (selectedDevice?.id === actionDevice.id) { + setSelectedDevice(null); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to remove device'); + } finally { + setIsRemovingDevice(false); + setIsRemoveDeviceAlertOpen(false); + setActionDevice(null); + } + } + if (selectedDevice) { return setSelectedDevice(null)} />; } @@ -251,6 +300,7 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps) Last Check-in Checks Compliant + ACTIONS @@ -295,11 +345,50 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps) + +
+ + e.stopPropagation()} + className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + + + + { + e.stopPropagation(); + setActionDevice(device); + setIsRemoveDeviceAlertOpen(true); + }} + variant="destructive" + > + + Remove Device + + + +
+
))}
)} + + Are you sure you want to remove this device{' '} + {actionDevice?.name ?? 'device'}? + + } + onOpenChange={setIsRemoveDeviceAlertOpen} + onRemove={handleRemoveDevice} + isRemoving={isRemovingDevice} + /> ); }; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx index 05234729a1..be7478e1c7 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx @@ -66,7 +66,10 @@ export function DevicesTabContent({ isCurrentUserOwner }: DevicesTabContentProps /> {agentDevices.length > 0 && ( - + )} {isFleetLoading ? ( diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx index 0adb3a8785..95b24b7d09 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx @@ -39,6 +39,7 @@ import { TabsTrigger, Text, } from '@trycompai/design-system'; +import { SubtractAlt } from '@trycompai/design-system/icons'; import { CheckCircle2, Clock, Download, RefreshCw, SendHorizontal, Trash2, XCircle } from 'lucide-react'; import Link from 'next/link'; import { useParams, useSearchParams } from 'next/navigation'; @@ -177,7 +178,9 @@ export function SingleTask({ }; const handleUpdateTask = async ( - updates: Partial>, + updates: Partial> & { + notRelevantJustification?: string; + }, ) => { try { await updateTask({ @@ -187,6 +190,7 @@ export function SingleTask({ frequency: updates.frequency, department: updates.department, reviewDate: updates.reviewDate ? String(updates.reviewDate) : undefined, + notRelevantJustification: updates.notRelevantJustification, }); toast.success('Task updated'); mutateActivity(); @@ -318,6 +322,11 @@ export function SingleTask({ )} + {/* Not Relevant Banner */} + {task.status === 'not_relevant' && task.notRelevantJustification && ( + + )} + {/* Approval Banner */} {evidenceApprovalEnabled && isInReview && ( ; } +function NotRelevantBanner({ justification }: { justification: string }) { + return ( +
+ + + + Marked as Not Relevant + {justification} + + +
+ ); +} + function ApprovalBanner({ canApprove, canCancel, diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx index 2a71aa5f91..7e990e5c2a 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx @@ -22,12 +22,16 @@ import { Text, } from '@trycompai/design-system'; import { Calendar } from 'lucide-react'; +import { useState } from 'react'; +import { NotRelevantJustificationDialog } from '../../components/NotRelevantJustificationDialog'; import { useTask } from '../hooks/use-task'; import { taskStatuses, taskFrequencies, taskDepartments } from './constants'; interface TaskPropertiesSidebarProps { handleUpdateTask: ( - data: Partial>, + data: Partial> & { + notRelevantJustification?: string; + }, ) => void; evidenceApprovalEnabled?: boolean; onRequestApproval?: () => void; @@ -43,6 +47,7 @@ export function TaskPropertiesSidebar({ const { members } = useOrganizationMembers(); const { hasPermission } = usePermissions(); const canUpdate = hasPermission('task', 'update'); + const [justificationDialogOpen, setJustificationDialogOpen] = useState(false); if (isLoading || !task) return null; @@ -55,10 +60,23 @@ export function TaskPropertiesSidebar({ onRequestApproval(); return; } + if (selectedStatus === 'not_relevant') { + setJustificationDialogOpen(true); + return; + } handleUpdateTask({ status: selectedStatus as TaskStatus }); }; + const handleNotRelevantConfirm = (justification: string) => { + handleUpdateTask({ + status: 'not_relevant' as TaskStatus, + notRelevantJustification: justification, + }); + setJustificationDialogOpen(false); + }; + return ( + <>
@@ -170,5 +188,12 @@ export function TaskPropertiesSidebar({
+ + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task.ts index 982f76e65e..ba41cabbe5 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task.ts @@ -18,6 +18,7 @@ interface UpdateTaskPayload { title?: string; description?: string; integrationScheduleFrequency?: TaskFrequency; + notRelevantJustification?: string; } interface UseTaskReturn { diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx index 7ce9871241..a46c1613cb 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx @@ -1,6 +1,7 @@ 'use client'; import { SelectAssignee } from '@/components/SelectAssignee'; +import { Label, Textarea } from '@trycompai/design-system'; import { Button } from '@trycompai/ui/button'; import { Dialog, @@ -10,7 +11,6 @@ import { DialogHeader, DialogTitle, } from '@trycompai/ui/dialog'; -import { Label } from '@trycompai/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@trycompai/ui/select'; import { Member, TaskStatus, User } from '@db'; import { Loader2 } from 'lucide-react'; @@ -48,16 +48,19 @@ export function BulkTaskStatusChangeModal({ const [status, setStatus] = useState(defaultStatus); const [isSubmitting, setIsSubmitting] = useState(false); const [approverId, setApproverId] = useState(null); + const [justification, setJustification] = useState(''); const selectedCount = selectedTaskIds.length; const isSingular = selectedCount === 1; // Whether we need approval for this status change const needsApproval = evidenceApprovalEnabled && status === TaskStatus.done; + const isNotRelevant = status === TaskStatus.not_relevant; useEffect(() => { if (open) { setStatus(defaultStatus); setApproverId(null); + setJustification(''); } }, [defaultStatus, open]); @@ -82,7 +85,12 @@ export function BulkTaskStatusChangeModal({ } else { // Normal bulk status change using hook const reviewDate = status === TaskStatus.done ? new Date().toISOString() : undefined; - const { updatedCount } = await bulkUpdateStatus(selectedTaskIds, status, reviewDate); + const { updatedCount } = await bulkUpdateStatus( + selectedTaskIds, + status, + reviewDate, + isNotRelevant ? justification.trim() || undefined : undefined, + ); toast.success(`Updated ${updatedCount} task${updatedCount === 1 ? '' : 's'}`); } @@ -142,6 +150,22 @@ export function BulkTaskStatusChangeModal({ /> )} + + {isNotRelevant && ( +
+ +