From 1b62b52df3577e1793d5d3770405583ae0d27195 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 18:29:25 +0100 Subject: [PATCH 1/5] feat: add pipeda and ccpa frameworks CS-343 [New Framework] - PIPEDA, CS-333 [New Framework] - CCPA --- .../src/trust-portal/trust-access.service.ts | 7 + .../src/trust-portal/trust-portal.service.ts | 28 ++- apps/app/public/badges/ccpa.svg | 7 + apps/app/public/badges/pipeda.svg | 6 + .../frameworks/components/FrameworksTable.tsx | 2 + .../components/FrameworksOverview.tsx | 9 + apps/app/src/app/(app)/[orgId]/trust/page.tsx | 10 + .../components/TrustPortalSwitch.tsx | 124 ++++++++++- .../portal-settings/components/logos.tsx | 198 ++++++++++++++++++ .../migration.sql | 7 + .../migration.sql | 3 + packages/db/prisma/schema/trust.prisma | 6 + 12 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 apps/app/public/badges/ccpa.svg create mode 100644 apps/app/public/badges/pipeda.svg create mode 100644 packages/db/prisma/migrations/20260507154000_add_pipeda_ccpa_fields/migration.sql create mode 100644 packages/db/prisma/migrations/20260507154100_add_pipeda_ccpa_to_trust_framework/migration.sql 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]/trust/page.tsx b/apps/app/src/app/(app)/[orgId]/trust/page.tsx index 32b1f80c42..b980b263c3 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/page.tsx @@ -47,6 +47,8 @@ export default async function TrustPage({ pci_dss: 'pcidssFileName', nen_7510: 'nen7510FileName', iso_9001: 'iso9001FileName', + pipeda: 'pipedaFileName', + ccpa: 'ccpaFileName', }; const certificateFiles: Record = { @@ -60,6 +62,8 @@ export default async function TrustPage({ pcidssFileName: null, nen7510FileName: null, iso9001FileName: null, + pipedaFileName: null, + ccpaFileName: null, }; for (const resource of certificateResources) { @@ -110,6 +114,8 @@ export default async function TrustPage({ pcidss={settings?.pcidss ?? false} nen7510={settings?.nen7510 ?? false} iso9001={settings?.iso9001 ?? false} + pipeda={settings?.pipeda ?? false} + ccpa={settings?.ccpa ?? false} soc2type1Status={settings?.soc2type1Status ?? 'started'} soc2type2Status={settings?.soc2type2Status ?? 'started'} soc3Status={settings?.soc3Status ?? 'started'} @@ -120,6 +126,8 @@ export default async function TrustPage({ pcidssStatus={settings?.pcidssStatus ?? 'started'} nen7510Status={settings?.nen7510Status ?? 'started'} iso9001Status={settings?.iso9001Status ?? 'started'} + pipedaStatus={settings?.pipedaStatus ?? 'started'} + ccpaStatus={settings?.ccpaStatus ?? 'started'} faqs={settings?.faqs ?? null} iso27001FileName={certificateFiles.iso27001FileName} iso42001FileName={certificateFiles.iso42001FileName} @@ -131,6 +139,8 @@ export default async function TrustPage({ pcidssFileName={certificateFiles.pcidssFileName} nen7510FileName={certificateFiles.nen7510FileName} iso9001FileName={certificateFiles.iso9001FileName} + pipedaFileName={certificateFiles.pipedaFileName} + ccpaFileName={certificateFiles.ccpaFileName} additionalDocuments={documents.map((doc: any) => ({ id: doc.id, name: doc.name, diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx index 40cf35037c..f8abc03618 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx @@ -36,6 +36,8 @@ import { GDPRInProgress, HIPAA, HIPAAInProgress, + CCPA, + CCPAInProgress, ISO27001, ISO27001InProgress, ISO42001, @@ -46,6 +48,8 @@ import { NEN7510InProgress, PCIDSS, PCIDSSInProgress, + PIPEDA, + PIPEDAInProgress, SOC2Type1, SOC2Type1InProgress, SOC2Type2, @@ -78,6 +82,8 @@ const trustPortalSwitchSchema = z.object({ pcidss: z.boolean(), nen7510: z.boolean(), iso9001: z.boolean(), + pipeda: z.boolean(), + ccpa: z.boolean(), soc2type1Status: z.enum(['started', 'in_progress', 'compliant']), soc2type2Status: z.enum(['started', 'in_progress', 'compliant']), soc3Status: z.enum(['started', 'in_progress', 'compliant']), @@ -88,6 +94,8 @@ const trustPortalSwitchSchema = z.object({ pcidssStatus: z.enum(['started', 'in_progress', 'compliant']), nen7510Status: z.enum(['started', 'in_progress', 'compliant']), iso9001Status: z.enum(['started', 'in_progress', 'compliant']), + pipedaStatus: z.enum(['started', 'in_progress', 'compliant']), + ccpaStatus: z.enum(['started', 'in_progress', 'compliant']), }); // Server action input schema (only fields that the server accepts) @@ -108,6 +116,8 @@ const FRAMEWORK_KEY_TO_API_SLUG: Record = { pcidss: 'pci_dss', nen7510: 'nen_7510', iso9001: 'iso_9001', + pipeda: 'pipeda', + ccpa: 'ccpa', }; interface ComplianceResourceResponse { @@ -139,7 +149,15 @@ type TrustCustomLink = { }; type ComplianceBadge = { - type: 'soc2' | 'iso27001' | 'iso42001' | 'gdpr' | 'hipaa' | 'pci_dss' | 'nen7510' | 'iso9001'; + type: + | 'soc2' + | 'iso27001' + | 'iso42001' + | 'gdpr' + | 'hipaa' + | 'pci_dss' + | 'nen7510' + | 'iso9001'; verified: boolean; }; @@ -181,6 +199,10 @@ export function TrustPortalSwitch({ nen7510Status, iso9001, iso9001Status, + pipeda, + pipedaStatus, + ccpa, + ccpaStatus, faqs, iso27001FileName, iso42001FileName, @@ -192,6 +214,8 @@ export function TrustPortalSwitch({ pcidssFileName, nen7510FileName, iso9001FileName, + pipedaFileName, + ccpaFileName, additionalDocuments, overview, customLinks, @@ -214,6 +238,8 @@ export function TrustPortalSwitch({ hipaa: boolean; pcidss: boolean; nen7510: boolean; + pipeda: boolean; + ccpa: boolean; soc2type1Status: 'started' | 'in_progress' | 'compliant'; soc2type2Status: 'started' | 'in_progress' | 'compliant'; soc3Status: 'started' | 'in_progress' | 'compliant'; @@ -225,6 +251,8 @@ export function TrustPortalSwitch({ nen7510Status: 'started' | 'in_progress' | 'compliant'; iso9001: boolean; iso9001Status: 'started' | 'in_progress' | 'compliant'; + pipedaStatus: 'started' | 'in_progress' | 'compliant'; + ccpaStatus: 'started' | 'in_progress' | 'compliant'; faqs: any[] | null; iso27001FileName?: string | null; iso42001FileName?: string | null; @@ -236,6 +264,8 @@ export function TrustPortalSwitch({ pcidssFileName?: string | null; nen7510FileName?: string | null; iso9001FileName?: string | null; + pipedaFileName?: string | null; + ccpaFileName?: string | null; additionalDocuments: TrustPortalDocument[]; overview: TrustOverviewData; customLinks: TrustCustomLink[]; @@ -262,6 +292,8 @@ export function TrustPortalSwitch({ pcidss: pcidssFileName ?? null, nen7510: nen7510FileName ?? null, iso9001: iso9001FileName ?? null, + pipeda: pipedaFileName ?? null, + ccpa: ccpaFileName ?? null, }); useEffect(() => { @@ -276,6 +308,8 @@ export function TrustPortalSwitch({ pcidss: pcidssFileName ?? null, nen7510: nen7510FileName ?? null, iso9001: iso9001FileName ?? null, + pipeda: pipedaFileName ?? null, + ccpa: ccpaFileName ?? null, }); }, [ iso27001FileName, @@ -288,6 +322,8 @@ export function TrustPortalSwitch({ pcidssFileName, nen7510FileName, iso9001FileName, + pipedaFileName, + ccpaFileName, ]); const convertFileToBase64 = async (file: File): Promise => { @@ -352,6 +388,8 @@ export function TrustPortalSwitch({ pcidss: pcidss ?? false, nen7510: nen7510 ?? false, iso9001: iso9001 ?? false, + pipeda: pipeda ?? false, + ccpa: ccpa ?? false, soc2type1Status: soc2type1Status ?? 'started', soc2type2Status: soc2type2Status ?? 'started', soc3Status: soc3Status ?? 'started', @@ -362,6 +400,8 @@ export function TrustPortalSwitch({ pcidssStatus: pcidssStatus ?? 'started', nen7510Status: nen7510Status ?? 'started', iso9001Status: iso9001Status ?? 'started', + pipedaStatus: pipedaStatus ?? 'started', + ccpaStatus: ccpaStatus ?? 'started', }, }); @@ -898,6 +938,84 @@ export function TrustPortalSwitch({ orgId={orgId} disabled={!canUpdate} /> + {/* PIPEDA */} + { + try { + await updateFrameworkSettings({ + pipedaStatus: value as 'started' | 'in_progress' | 'compliant', + }); + toast.success('PIPEDA status updated'); + } catch (error) { + console.error('[trust framework update] failed', error); + toast.error('Failed to update PIPEDA status', { + description: error instanceof Error ? error.message : undefined, + }); + } + }} + onToggle={async (checked) => { + try { + await updateFrameworkSettings({ + pipeda: checked, + }); + toast.success('PIPEDA status updated'); + } catch (error) { + console.error('[trust framework update] failed', error); + toast.error('Failed to update PIPEDA status', { + description: error instanceof Error ? error.message : undefined, + }); + } + }} + fileName={certificateFiles.pipeda} + onFileUpload={handleFileUpload} + onFilePreview={handleFilePreview} + frameworkKey="pipeda" + orgId={orgId} + disabled={!canUpdate} + /> + {/* CCPA */} + { + try { + await updateFrameworkSettings({ + ccpaStatus: value as 'started' | 'in_progress' | 'compliant', + }); + toast.success('CCPA status updated'); + } catch (error) { + console.error('[trust framework update] failed', error); + toast.error('Failed to update CCPA status', { + description: error instanceof Error ? error.message : undefined, + }); + } + }} + onToggle={async (checked) => { + try { + await updateFrameworkSettings({ + ccpa: checked, + }); + toast.success('CCPA status updated'); + } catch (error) { + console.error('[trust framework update] failed', error); + toast.error('Failed to update CCPA status', { + description: error instanceof Error ? error.message : undefined, + }); + } + }} + fileName={certificateFiles.ccpa} + onFileUpload={handleFileUpload} + onFilePreview={handleFilePreview} + frameworkKey="ccpa" + orgId={orgId} + disabled={!canUpdate} + /> @@ -978,8 +1096,12 @@ function ComplianceFrameworkLogo({ LogoComponent = enabled && isInProgress ? SOC2Type1InProgress : SOC2Type1; } else if (title === 'SOC 2 Type 2') { LogoComponent = enabled && isInProgress ? SOC2Type2InProgress : SOC2Type2; + } else if (title === 'CCPA') { + LogoComponent = enabled && isInProgress ? CCPAInProgress : CCPA; } else if (title === 'PCI DSS') { LogoComponent = enabled && isInProgress ? PCIDSSInProgress : PCIDSS; + } else if (title === 'PIPEDA') { + LogoComponent = enabled && isInProgress ? PIPEDAInProgress : PIPEDA; } else if (title === 'NEN 7510') { LogoComponent = enabled && isInProgress ? NEN7510InProgress : NEN7510; } else if (title === 'ISO 9001') { diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/logos.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/logos.tsx index cfc333204f..b7105734d7 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/logos.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/logos.tsx @@ -945,3 +945,201 @@ export const SOC3InProgress = (props: React.SVGProps) => ( ); + +export const PIPEDA = (props: React.SVGProps) => ( + + + + + + + + + + + + + +); + +export const PIPEDAInProgress = (props: React.SVGProps) => ( + + + + + + + + + + + + + +); + +export const CCPA = (props: React.SVGProps) => ( + + + + + + + + + + + + + + +); + +export const CCPAInProgress = (props: React.SVGProps) => ( + + + + + + + + + + + + + + + +); diff --git a/packages/db/prisma/migrations/20260507154000_add_pipeda_ccpa_fields/migration.sql b/packages/db/prisma/migrations/20260507154000_add_pipeda_ccpa_fields/migration.sql new file mode 100644 index 0000000000..a512269f9c --- /dev/null +++ b/packages/db/prisma/migrations/20260507154000_add_pipeda_ccpa_fields/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "public"."Trust" +ADD COLUMN "pipeda" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "pipeda_status" "public"."FrameworkStatus" NOT NULL DEFAULT 'started', +ADD COLUMN "ccpa" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "ccpa_status" "public"."FrameworkStatus" NOT NULL DEFAULT 'started'; + diff --git a/packages/db/prisma/migrations/20260507154100_add_pipeda_ccpa_to_trust_framework/migration.sql b/packages/db/prisma/migrations/20260507154100_add_pipeda_ccpa_to_trust_framework/migration.sql new file mode 100644 index 0000000000..1f62668b85 --- /dev/null +++ b/packages/db/prisma/migrations/20260507154100_add_pipeda_ccpa_to_trust_framework/migration.sql @@ -0,0 +1,3 @@ +-- Add PIPEDA and CCPA certificate support to TrustResource framework enum. +ALTER TYPE "TrustFramework" ADD VALUE IF NOT EXISTS 'pipeda'; +ALTER TYPE "TrustFramework" ADD VALUE IF NOT EXISTS 'ccpa'; diff --git a/packages/db/prisma/schema/trust.prisma b/packages/db/prisma/schema/trust.prisma index 5edcb4075a..0f34261861 100644 --- a/packages/db/prisma/schema/trust.prisma +++ b/packages/db/prisma/schema/trust.prisma @@ -25,6 +25,8 @@ model Trust { hipaa Boolean @default(false) pci_dss Boolean @default(false) iso9001 Boolean @default(false) + pipeda Boolean @default(false) + ccpa Boolean @default(false) soc2_status FrameworkStatus @default(started) soc2type1_status FrameworkStatus @default(started) @@ -37,6 +39,8 @@ model Trust { hipaa_status FrameworkStatus @default(started) pci_dss_status FrameworkStatus @default(started) iso9001_status FrameworkStatus @default(started) + pipeda_status FrameworkStatus @default(started) + ccpa_status FrameworkStatus @default(started) // Overview section for public trust portal overviewTitle String? @@ -74,6 +78,8 @@ enum TrustFramework { pci_dss nen_7510 iso_9001 + pipeda + ccpa } model TrustResource { From 863a4673a2ec044f0eceebc7147c695b186d9520 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 19:11:35 +0100 Subject: [PATCH 2/5] feat(app): add 'Remove Device' menu on Devices tab * feat(app): add 'Remove Device' menu on Devices tab * feat(api): define DELETE endpoint to remove single device agent * feat(app): integrate remove-device endpoint on Devices tab * fix(api): set permission to remove-device-agent endpoint * fix(app): use people action hook for agent device removal --------- Co-authored-by: chasprowebdev Co-authored-by: chasprowebdev <70908289+chasprowebdev@users.noreply.github.com> --- .../src/devices/devices.controller.spec.ts | 25 ++++ apps/api/src/devices/devices.controller.ts | 39 ++++- apps/api/src/devices/devices.service.spec.ts | 134 ++++++++++++++++++ apps/api/src/devices/devices.service.ts | 67 ++++++++- .../components/DeviceAgentDevicesList.tsx | 93 +++++++++++- .../devices/components/DevicesTabContent.tsx | 5 +- apps/app/src/hooks/use-people-api.ts | 14 ++ 7 files changed, 372 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/devices/devices.service.spec.ts 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/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/hooks/use-people-api.ts b/apps/app/src/hooks/use-people-api.ts index 34b043836b..2143df1986 100644 --- a/apps/app/src/hooks/use-people-api.ts +++ b/apps/app/src/hooks/use-people-api.ts @@ -71,6 +71,19 @@ export function usePeopleActions() { [api], ); + const removeDeviceAgent = useCallback( + async (deviceId: string) => { + const response = await api.delete<{ + success?: boolean; + }>(`/v1/devices/${deviceId}`); + if (response.error) { + throw new Error(response.error); + } + return response.data; + }, + [api], + ); + const reactivateMember = useCallback( async (memberId: string) => { const response = await api.patch( @@ -88,6 +101,7 @@ export function usePeopleActions() { unlinkDevice, removeMember, removeHostFromFleet, + removeDeviceAgent, reactivateMember, }; } From d044c092fd771c5ab2393405983d23a8b285a7f4 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 8 May 2026 10:10:54 +0100 Subject: [PATCH 3/5] fix(db): update screenlock seed data to 15 minutes for all platforms (#2797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device agent already enforces 15 minutes across macOS, Linux, and Windows — the seed description still said 5 minutes for macOS. Co-authored-by: Claude Opus 4.6 (1M context) --- .../db/prisma/seed/primitives/FrameworkEditorTaskTemplate.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/prisma/seed/primitives/FrameworkEditorTaskTemplate.json b/packages/db/prisma/seed/primitives/FrameworkEditorTaskTemplate.json index 7941651fff..f7017485d5 100644 --- a/packages/db/prisma/seed/primitives/FrameworkEditorTaskTemplate.json +++ b/packages/db/prisma/seed/primitives/FrameworkEditorTaskTemplate.json @@ -682,7 +682,7 @@ { "id": "frk_tt_6840796f77d8a0dff53f947a", "name": "Secure Devices", - "description": "Ensure all devices meet the following security requirements:\n\n• Full disk encryption enabled (BitLocker for Windows, FileVault for macOS)\n\n• Screen lock enabled after 5 minutes of inactivity on macOS and 15 minutes on Windows\n\n• Minimum password length of 8+ characters\n\n• Automatic installation of security updates\n\n• Anti-virus enabled (Windows Defender on Windows or macOS XProtect)\n\nYou may verify compliance by uploading screenshots for each device or by installing the Comp AI device agent.\n\nIf you already use a third-party MDM provider (e.g., Jamf, Intune, Kandji, etc.), it is recommended that you do not install the Comp AI device agent, as running multiple device management agents may cause conflicts. In these cases, please upload screenshots instead.\n\nThe Comp AI device agent can be downloaded from: portal.trycomp.ai", + "description": "Ensure all devices meet the following security requirements:\n\n• Full disk encryption enabled (BitLocker for Windows, FileVault for macOS)\n\n• Screen lock enabled after 15 minutes of inactivity\n\n• Minimum password length of 8+ characters\n\n• Automatic installation of security updates\n\n• Anti-virus enabled (Windows Defender on Windows or macOS XProtect)\n\nYou may verify compliance by uploading screenshots for each device or by installing the Comp AI device agent.\n\nIf you already use a third-party MDM provider (e.g., Jamf, Intune, Kandji, etc.), it is recommended that you do not install the Comp AI device agent, as running multiple device management agents may cause conflicts. In these cases, please upload screenshots instead.\n\nThe Comp AI device agent can be downloaded from: portal.trycomp.ai", "frequency": "yearly", "department": "itsm", "createdAt": "2025-06-04 16:50:54.671", From e47dc0c2b680feb70bfd7c1424c4749812bd7d03 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 8 May 2026 10:17:40 +0100 Subject: [PATCH 4/5] chore(trigger): change policy acknowledgment digest from daily to weekly (#2796) * chore(trigger): change policy acknowledgment digest from daily to weekly Reduces email frequency for policy signature reminders from daily to weekly (Mondays at 14:00 UTC) to avoid notification fatigue. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(trigger): run policy acknowledgment digest on Tuesdays Co-Authored-By: Claude Opus 4.7 (1M context) * feat(email): use Resend batch API for policy acknowledgment digest Adds a new `send-batch-email` Trigger.dev task that calls `resend.batch.send()` (up to 100 emails per API call) with permissive validation for partial-failure reporting. - New API endpoint `POST /v1/internal/email/send-batch` - New `sendBatchEmailViaApi` helper for app-side Trigger tasks - Digest task now renders HTML upfront, groups by org, and sends one batch request per org instead of one HTTP call per recipient - Unsubscribe headers included per-email in the batch payload Co-Authored-By: Claude Opus 4.7 (1M context) * fix(email): address batch email edge cases from review - Guard against missing FROM address env vars (throw early instead of sending empty string) - Fix totalSent metric: data.data only contains successes, so don't decrement for permissive-mode errors - Wrap per-recipient render() in try/catch so one bad template doesn't abort the entire digest run - Validate `to` field as email address in batch DTO Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../api/src/email/dto/send-batch-email.dto.ts | 45 ++++++++ apps/api/src/email/email.controller.ts | 29 +++++ .../api/src/trigger/email/send-batch-email.ts | 108 ++++++++++++++++++ .../app/src/trigger/lib/send-email-via-api.ts | 52 +++++++++ .../task/policy-acknowledgment-digest.test.ts | 84 +++++++------- .../task/policy-acknowledgment-digest.ts | 71 ++++++------ 6 files changed, 313 insertions(+), 76 deletions(-) create mode 100644 apps/api/src/email/dto/send-batch-email.dto.ts create mode 100644 apps/api/src/trigger/email/send-batch-email.ts 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/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/app/src/trigger/lib/send-email-via-api.ts b/apps/app/src/trigger/lib/send-email-via-api.ts index c6331eb7e3..f2628d0239 100644 --- a/apps/app/src/trigger/lib/send-email-via-api.ts +++ b/apps/app/src/trigger/lib/send-email-via-api.ts @@ -17,6 +17,19 @@ interface SendEmailViaApiParams { cc?: string | string[]; } +interface BatchEmailItem { + to: string; + subject: string; + html: string; + from?: string; + cc?: string | string[]; +} + +interface SendBatchEmailViaApiParams { + emails: BatchEmailItem[]; + organizationId: string; +} + /** * Renders a React email template to HTML and sends it through the * API's centralized send-email Trigger task. @@ -64,3 +77,42 @@ export async function sendEmailViaApi( }); return { taskId: data.taskId }; } + +/** + * Sends a batch of pre-rendered HTML emails through the API's + * centralized send-batch-email Trigger task. + */ +export async function sendBatchEmailViaApi( + params: SendBatchEmailViaApiParams, +): Promise<{ taskId: string }> { + const apiBaseUrl = getApiBaseUrl(); + const token = process.env.SERVICE_TOKEN_TRIGGER; + + const response = await fetch(`${apiBaseUrl}/v1/internal/email/send-batch`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token + ? { + 'x-service-token': token, + 'x-organization-id': params.organizationId, + } + : {}), + }, + body: JSON.stringify({ emails: params.emails }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to send batch email via API (${response.status}): ${text}`, + ); + } + + const data = (await response.json()) as { taskId: string }; + logger.info('Batch email triggered via API', { + count: params.emails.length, + taskId: data.taskId, + }); + return { taskId: data.taskId }; +} diff --git a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.test.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.test.ts index 524f7eaf83..b5dc0b9ff0 100644 --- a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.test.ts +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.test.ts @@ -14,8 +14,12 @@ vi.mock('./policy-acknowledgment-digest-helpers', async (importOriginal) => { }; }); +vi.mock('@react-email/render', () => ({ + render: vi.fn().mockResolvedValue('mock'), +})); + vi.mock('../../lib/send-email-via-api', () => ({ - sendEmailViaApi: vi.fn(), + sendBatchEmailViaApi: vi.fn(), })); vi.mock('@trycompai/email/lib/check-unsubscribe', () => ({ @@ -31,7 +35,7 @@ vi.mock('@trigger.dev/sdk', () => ({ import { db } from '@db/server'; import { filterDigestMembersByCompliance } from './policy-acknowledgment-digest-helpers'; -import { sendEmailViaApi } from '../../lib/send-email-via-api'; +import { sendBatchEmailViaApi } from '../../lib/send-email-via-api'; import { getUnsubscribedEmails } from '@trycompai/email/lib/check-unsubscribe'; import { policyAcknowledgmentDigest } from './policy-acknowledgment-digest'; @@ -40,7 +44,7 @@ const mockDb = db as unknown as { }; const mockFindMany = mockDb.organization.findMany; const mockFilterDigestMembersByCompliance = vi.mocked(filterDigestMembersByCompliance); -const mockSendEmailViaApi = vi.mocked(sendEmailViaApi); +const mockSendBatchEmailViaApi = vi.mocked(sendBatchEmailViaApi); const mockGetUnsubscribedEmails = vi.mocked(getUnsubscribedEmails); // The mock replaces schedules.task with a passthrough that returns the config @@ -60,7 +64,7 @@ describe('policyAcknowledgmentDigest', () => { beforeEach(() => { vi.clearAllMocks(); mockFilterDigestMembersByCompliance.mockImplementation(async (_db, members) => members); - mockSendEmailViaApi.mockResolvedValue({ taskId: 'run_fake' }); + mockSendBatchEmailViaApi.mockResolvedValue({ taskId: 'run_fake' }); mockGetUnsubscribedEmails.mockResolvedValue(new Set()); }); @@ -104,11 +108,12 @@ describe('policyAcknowledgmentDigest', () => { timestamp: new Date(), } as never); - expect(mockSendEmailViaApi).toHaveBeenCalledTimes(1); - const call = mockSendEmailViaApi.mock.calls[0][0]; - expect(call.to).toBe('alice@example.com'); - expect(call.subject).toBe('You have 1 policy to review at Acme'); + expect(mockSendBatchEmailViaApi).toHaveBeenCalledTimes(1); + const call = mockSendBatchEmailViaApi.mock.calls[0][0]; expect(call.organizationId).toBe('org_1'); + expect(call.emails).toHaveLength(1); + expect(call.emails[0].to).toBe('alice@example.com'); + expect(call.emails[0].subject).toBe('You have 1 policy to review at Acme'); expect(result).toMatchObject({ success: true, emailsSent: 1, @@ -149,7 +154,7 @@ describe('policyAcknowledgmentDigest', () => { timestamp: new Date(), } as never); - expect(mockSendEmailViaApi).not.toHaveBeenCalled(); + expect(mockSendBatchEmailViaApi).not.toHaveBeenCalled(); expect(result).toMatchObject({ success: true, emailsSent: 0 }); }); @@ -187,7 +192,7 @@ describe('policyAcknowledgmentDigest', () => { timestamp: new Date(), } as never); - expect(mockSendEmailViaApi).not.toHaveBeenCalled(); + expect(mockSendBatchEmailViaApi).not.toHaveBeenCalled(); expect(result).toMatchObject({ success: true, emailsSent: 0 }); }); @@ -236,8 +241,8 @@ describe('policyAcknowledgmentDigest', () => { await taskUnderTest.run({ timestamp: new Date() } as never); - expect(mockSendEmailViaApi).toHaveBeenCalledTimes(1); - expect(mockSendEmailViaApi.mock.calls[0][0].subject).toBe( + expect(mockSendBatchEmailViaApi).toHaveBeenCalledTimes(1); + expect(mockSendBatchEmailViaApi.mock.calls[0][0].emails[0].subject).toBe( 'You have 3 policies to review at Acme', ); }); @@ -280,19 +285,18 @@ describe('policyAcknowledgmentDigest', () => { ], }, ]); - mockSendEmailViaApi - .mockRejectedValueOnce(new Error('Resend 500')) - .mockResolvedValueOnce({ taskId: 'run_ok' }); + mockSendBatchEmailViaApi.mockRejectedValueOnce(new Error('Resend 500')); const result = await taskUnderTest.run({ timestamp: new Date(), } as never); - expect(mockSendEmailViaApi).toHaveBeenCalledTimes(2); + expect(mockSendBatchEmailViaApi).toHaveBeenCalledTimes(1); + expect(mockSendBatchEmailViaApi.mock.calls[0][0].emails).toHaveLength(2); expect(result).toMatchObject({ success: true, - emailsSent: 1, - emailsFailed: 1, + emailsSent: 0, + emailsFailed: 2, }); }); @@ -330,7 +334,7 @@ describe('policyAcknowledgmentDigest', () => { const result = await taskUnderTest.run({ timestamp: new Date() } as never); - expect(mockSendEmailViaApi).not.toHaveBeenCalled(); + expect(mockSendBatchEmailViaApi).not.toHaveBeenCalled(); expect(result).toMatchObject({ success: true, emailsSent: 0, @@ -401,17 +405,16 @@ describe('policyAcknowledgmentDigest', () => { const result = await taskUnderTest.run({ timestamp: new Date() } as never); - expect(mockSendEmailViaApi).toHaveBeenCalledTimes(1); - const call = mockSendEmailViaApi.mock.calls[0][0] as { - to: string; - subject: string; + expect(mockSendBatchEmailViaApi).toHaveBeenCalledTimes(1); + const call = mockSendBatchEmailViaApi.mock.calls[0][0] as { + emails: Array<{ to: string; subject: string }>; organizationId: string; }; - expect(call.to).toBe('alice@example.com'); - expect(call.subject).toBe( + expect(call.emails).toHaveLength(1); + expect(call.emails[0].to).toBe('alice@example.com'); + expect(call.emails[0].subject).toBe( 'You have 3 policies to review across 2 organizations', ); - // x-organization-id falls back to the first org the user had policies in. expect(call.organizationId).toBe('org_1'); expect(result).toMatchObject({ success: true, @@ -484,13 +487,12 @@ describe('policyAcknowledgmentDigest', () => { const result = await taskUnderTest.run({ timestamp: new Date() } as never); - expect(mockSendEmailViaApi).toHaveBeenCalledTimes(1); - const call = mockSendEmailViaApi.mock.calls[0][0] as { - to: string; - subject: string; + expect(mockSendBatchEmailViaApi).toHaveBeenCalledTimes(1); + const call = mockSendBatchEmailViaApi.mock.calls[0][0] as { + emails: Array<{ to: string; subject: string }>; organizationId: string; }; - expect(call.subject).toBe( + expect(call.emails[0].subject).toBe( 'You have 2 policies to review across 2 organizations', ); expect(call.organizationId).toBe('org_1'); @@ -565,12 +567,12 @@ describe('policyAcknowledgmentDigest', () => { const result = await taskUnderTest.run({ timestamp: new Date() } as never); - expect(mockSendEmailViaApi).toHaveBeenCalledTimes(1); - const call = mockSendEmailViaApi.mock.calls[0][0] as { - subject: string; + expect(mockSendBatchEmailViaApi).toHaveBeenCalledTimes(1); + const call = mockSendBatchEmailViaApi.mock.calls[0][0] as { + emails: Array<{ subject: string }>; organizationId: string; }; - expect(call.subject).toBe('You have 1 policy to review at Beta'); + expect(call.emails[0].subject).toBe('You have 1 policy to review at Beta'); expect(call.organizationId).toBe('org_2'); expect(result).toMatchObject({ success: true, @@ -640,7 +642,7 @@ describe('policyAcknowledgmentDigest', () => { const result = await taskUnderTest.run({ timestamp: new Date() } as never); - expect(mockSendEmailViaApi).not.toHaveBeenCalled(); + expect(mockSendBatchEmailViaApi).not.toHaveBeenCalled(); expect(result).toMatchObject({ success: true, recipients: 0, @@ -682,12 +684,11 @@ describe('policyAcknowledgmentDigest', () => { const result = await taskUnderTest.run({ timestamp: new Date() } as never); - expect(mockSendEmailViaApi).not.toHaveBeenCalled(); + expect(mockSendBatchEmailViaApi).not.toHaveBeenCalled(); expect(result).toMatchObject({ success: true, emailsSent: 0 }); }); - it('sends emails in batches of up to 25', async () => { - // Create 60 members in one org, all with pending policies, all subscribed. + it('sends all emails for an org in a single batch call', async () => { const members = Array.from({ length: 60 }, (_, i) => ({ id: `mem_${i}`, department: 'it', @@ -716,12 +717,13 @@ describe('policyAcknowledgmentDigest', () => { }, ]); - // All subscribed mockGetUnsubscribedEmails.mockResolvedValueOnce(new Set()); const result = await taskUnderTest.run({ timestamp: new Date() } as never); - expect(mockSendEmailViaApi).toHaveBeenCalledTimes(60); + expect(mockSendBatchEmailViaApi).toHaveBeenCalledTimes(1); + expect(mockSendBatchEmailViaApi.mock.calls[0][0].emails).toHaveLength(60); + expect(mockSendBatchEmailViaApi.mock.calls[0][0].organizationId).toBe('org_big'); expect(result).toMatchObject({ success: true, emailsSent: 60 }); }); 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 a6d68c5527..3d8c293a4b 100644 --- a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts @@ -8,7 +8,8 @@ import { } from '@trycompai/email'; import { getUnsubscribedEmails } from '@trycompai/email/lib/check-unsubscribe'; -import { sendEmailViaApi } from '../../lib/send-email-via-api'; +import { render } from '@react-email/render'; +import { sendBatchEmailViaApi } from '../../lib/send-email-via-api'; import { computePendingPolicies, filterDigestMembersByCompliance, @@ -22,23 +23,10 @@ const getPortalBase = () => '', ); -const EMAIL_BATCH_SIZE = 25; // Skip orgs that look abandoned — same threshold weekly-task-reminder uses so // we don't keep hitting dead addresses and burning domain reputation. const ORG_INACTIVITY_DAYS = 90; -async function sendInBatches( - sends: Array<() => Promise>, -): Promise[]> { - const results: PromiseSettledResult[] = []; - for (let i = 0; i < sends.length; i += EMAIL_BATCH_SIZE) { - const chunk = sends.slice(i, i + EMAIL_BATCH_SIZE); - const chunkResults = await Promise.allSettled(chunk.map((fn) => fn())); - results.push(...chunkResults); - } - return results; -} - interface RollupEntry { email: string; userName: string; @@ -51,7 +39,7 @@ interface RollupEntry { export const policyAcknowledgmentDigest = schedules.task({ id: 'policy-acknowledgment-digest', machine: 'large-1x', - cron: '0 14 * * *', // Once daily at 14:00 UTC + cron: '0 14 * * 2', // Weekly on Tuesdays at 14:00 UTC maxDuration: 1000 * 60 * 15, // 15 minutes run: async () => { const inactivityCutoff = new Date(); @@ -197,8 +185,14 @@ export const policyAcknowledgmentDigest = schedules.task({ } } - // Build one send per user. - const sends: Array<() => Promise> = []; + // 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 }> + >(); + for (const entry of rollup.values()) { const subject = computePolicyAcknowledgmentDigestSubject(entry.orgs); const emailElement = PolicyAcknowledgmentDigestEmail({ @@ -208,26 +202,33 @@ export const policyAcknowledgmentDigest = schedules.task({ }); if (!emailElement) continue; - sends.push(() => - sendEmailViaApi({ - to: entry.email, - subject, - organizationId: entry.primaryOrgId, - react: emailElement, - }), - ); + let html: string; + try { + html = await render(emailElement); + } catch (error) { + logger.warn('Failed to render digest email, skipping recipient', { + email: entry.email, + error: error instanceof Error ? error.message : String(error), + }); + emailsFailed += 1; + continue; + } + + const orgEmails = emailsByOrg.get(entry.primaryOrgId) ?? []; + orgEmails.push({ to: entry.email, subject, html }); + emailsByOrg.set(entry.primaryOrgId, orgEmails); } - const results = await sendInBatches(sends); - let emailsSent = 0; - let emailsFailed = 0; - for (const r of results) { - if (r.status === 'fulfilled') emailsSent += 1; - else { - emailsFailed += 1; - logger.warn('Digest email failed', { - error: - r.reason instanceof Error ? r.reason.message : String(r.reason), + for (const [orgId, emails] of emailsByOrg) { + try { + await sendBatchEmailViaApi({ emails, organizationId: orgId }); + emailsSent += emails.length; + } catch (error) { + emailsFailed += emails.length; + logger.warn('Batch email request failed', { + orgId, + count: emails.length, + error: error instanceof Error ? error.message : String(error), }); } } From 2876232b7685f7a2cb5b66fad6cbd8e97ba1b883 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 8 May 2026 10:18:13 +0100 Subject: [PATCH 5/5] feat(tasks): add justification when marking evidence tasks as not relevant (#2798) Adds a required justification flow when marking evidence tasks as "not relevant" so auditors can review why a task was excluded. Shows a confirmation dialog with a textarea, stores the reason on the task, and displays a banner on the task detail page. Co-authored-by: Claude Opus 4.6 (1M context) --- apps/api/src/tasks/tasks.controller.ts | 18 +++- apps/api/src/tasks/tasks.service.ts | 21 +++++ .../tasks/[taskId]/components/SingleTask.tsx | 25 +++++- .../components/TaskPropertiesSidebar.tsx | 27 +++++- .../[orgId]/tasks/[taskId]/hooks/use-task.ts | 1 + .../components/BulkTaskStatusChangeModal.tsx | 28 ++++++- .../ModernSingleStatusTaskList.test.tsx | 2 + .../NotRelevantJustificationDialog.tsx | 79 +++++++++++++++++ .../tasks/components/TaskList.test.tsx | 1 + .../app/(app)/[orgId]/tasks/hooks/useTasks.ts | 5 +- .../migration.sql | 2 + packages/db/prisma/schema/task.prisma | 4 + packages/docs/openapi.json | 84 ++++++++++++++++++- 13 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/components/NotRelevantJustificationDialog.tsx create mode 100644 packages/db/prisma/migrations/20260508085655_add_not_relevant_justification/migration.sql 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/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 && ( + + Justification + setJustification(e.target.value)} + rows={3} + /> + + Auditors may review this justification during an audit. + + + )} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.test.tsx index 403f5c1127..a795ba53c6 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.test.tsx @@ -143,6 +143,7 @@ const mockTasks = [ approvalComment: null, organizationId: 'org_1', embeddingHash: null, + notRelevantJustification: null, controls: [], }, { @@ -170,6 +171,7 @@ const mockTasks = [ approvalComment: null, organizationId: 'org_1', embeddingHash: null, + notRelevantJustification: null, controls: [], }, ]; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/NotRelevantJustificationDialog.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/NotRelevantJustificationDialog.tsx new file mode 100644 index 0000000000..1482c6d931 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/NotRelevantJustificationDialog.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { Label, Textarea } from '@trycompai/design-system'; +import { Button } from '@trycompai/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@trycompai/ui/dialog'; +import { useState } from 'react'; + +interface NotRelevantJustificationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (justification: string) => void; + taskCount?: number; +} + +export function NotRelevantJustificationDialog({ + open, + onOpenChange, + onConfirm, + taskCount = 1, +}: NotRelevantJustificationDialogProps) { + const [justification, setJustification] = useState(''); + + const isSingular = taskCount === 1; + const taskLabel = isSingular ? 'this evidence task' : `these ${taskCount} evidence tasks`; + + const handleConfirm = () => { + onConfirm(justification.trim()); + setJustification(''); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) setJustification(''); + onOpenChange(nextOpen); + }; + + return ( + + + + Mark as Not Relevant + + Please provide a reason for marking {taskLabel} as not relevant. + Auditors may review this justification during an audit. + + + + + Justification + setJustification(e.target.value)} + rows={4} + /> + + + + handleOpenChange(false)}> + Cancel + + + Mark as Not Relevant + + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx index e3d0c90c25..1e47c68cf1 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx @@ -151,6 +151,7 @@ const baseMockTask = { approvedAt: null, approvalComment: null, embeddingHash: null, + notRelevantJustification: null, controls: [] as { id: string; name: string }[], }; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/hooks/useTasks.ts b/apps/app/src/app/(app)/[orgId]/tasks/hooks/useTasks.ts index fd7364090f..25259fff73 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/hooks/useTasks.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/hooks/useTasks.ts @@ -35,7 +35,7 @@ interface UseTasksReturn { isError: boolean; mutate: () => Promise; bulkDelete: (taskIds: string[]) => Promise<{ deletedCount: number }>; - bulkUpdateStatus: (taskIds: string[], status: TaskStatus, reviewDate?: string) => Promise<{ updatedCount: number }>; + bulkUpdateStatus: (taskIds: string[], status: TaskStatus, reviewDate?: string, notRelevantJustification?: string) => Promise<{ updatedCount: number }>; bulkUpdateAssignee: (taskIds: string[], assigneeId: string | null) => Promise<{ updatedCount: number }>; bulkSubmitForReview: (taskIds: string[], approverId: string) => Promise<{ submittedCount: number }>; createTask: (data: CreateTaskPayload) => Promise; @@ -101,9 +101,12 @@ export function useTasks({ initialData }: UseTasksOptions = {}): UseTasksReturn taskIds: string[], status: TaskStatus, reviewDate?: string, + notRelevantJustification?: string, ): Promise<{ updatedCount: number }> => { const payload: Record = { taskIds, status }; if (reviewDate) payload.reviewDate = reviewDate; + if (notRelevantJustification) + payload.notRelevantJustification = notRelevantJustification; const response = await apiClient.patch<{ updatedCount: number }>( '/v1/tasks/bulk', diff --git a/packages/db/prisma/migrations/20260508085655_add_not_relevant_justification/migration.sql b/packages/db/prisma/migrations/20260508085655_add_not_relevant_justification/migration.sql new file mode 100644 index 0000000000..a50d1081f8 --- /dev/null +++ b/packages/db/prisma/migrations/20260508085655_add_not_relevant_justification/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "notRelevantJustification" TEXT; diff --git a/packages/db/prisma/schema/task.prisma b/packages/db/prisma/schema/task.prisma index 7b44c7f6a3..ed056588ef 100644 --- a/packages/db/prisma/schema/task.prisma +++ b/packages/db/prisma/schema/task.prisma @@ -40,6 +40,10 @@ model Task { approvedAt DateTime? previousStatus TaskStatus? + // Justification provided when marking the task as not_relevant. + // Cleared when the task moves back to another status. + notRelevantJustification String? + // Sync-driven archive — see Control.archivedAt. archivedAt DateTime? diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index b9fe7944c4..0bb19f72aa 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -5778,6 +5778,44 @@ ] } }, + "/v1/devices/{id}": { + "delete": { + "description": "Deletes a single device in the authenticated organization. Only organization owners can delete devices.", + "operationId": "DevicesController_deleteDevice_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "Device ID to delete", + "schema": { + "example": "dev_abc123def456", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Device deleted successfully" + }, + "403": { + "description": "Forbidden - only organization owners can delete devices" + }, + "404": { + "description": "Organization or device not found" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Delete device", + "tags": [ + "Devices" + ] + } + }, "/v1/policies": { "get": { "description": "Returns all policies for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).", @@ -8458,6 +8496,11 @@ "format": "date-time", "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": [ @@ -8933,6 +8976,11 @@ "type": "string", "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" } } } @@ -12186,7 +12234,9 @@ "soc3", "pci_dss", "nen_7510", - "iso_9001" + "iso_9001", + "pipeda", + "ccpa" ], "type": "string" } @@ -19785,6 +19835,26 @@ ] } }, + "/v1/frameworks/update-statuses": { + "get": { + "operationId": "FrameworksController_getAllUpdateStatuses_v1", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Get update statuses for all framework instances", + "tags": [ + "Frameworks" + ] + } + }, "/v1/frameworks/{id}": { "get": { "operationId": "FrameworksController_findOne_v1", @@ -24334,7 +24404,9 @@ "soc3", "pci_dss", "nen_7510", - "iso_9001" + "iso_9001", + "pipeda", + "ccpa" ], "example": "iso_27001" }, @@ -24376,7 +24448,9 @@ "soc3", "pci_dss", "nen_7510", - "iso_9001" + "iso_9001", + "pipeda", + "ccpa" ] }, "fileName": { @@ -24419,7 +24493,9 @@ "soc3", "pci_dss", "nen_7510", - "iso_9001" + "iso_9001", + "pipeda", + "ccpa" ], "example": "iso_27001" }
+ Auditors may review this justification during an audit. +