From 42357e3d3dcbed3bd255b43d4783060e9afd7ff4 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 12 May 2026 16:18:30 -0400 Subject: [PATCH 01/13] fix(db): add finding to CommentEntityType --- .../migration.sql | 2 ++ packages/db/prisma/schema/comment.prisma | 1 + 2 files changed, 3 insertions(+) create mode 100644 packages/db/prisma/migrations/20260512120000_add_finding_to_comment_entity_type/migration.sql diff --git a/packages/db/prisma/migrations/20260512120000_add_finding_to_comment_entity_type/migration.sql b/packages/db/prisma/migrations/20260512120000_add_finding_to_comment_entity_type/migration.sql new file mode 100644 index 0000000000..cb81b52507 --- /dev/null +++ b/packages/db/prisma/migrations/20260512120000_add_finding_to_comment_entity_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "CommentEntityType" ADD VALUE 'finding'; diff --git a/packages/db/prisma/schema/comment.prisma b/packages/db/prisma/schema/comment.prisma index e00cb1d5cd..b0c440f69d 100644 --- a/packages/db/prisma/schema/comment.prisma +++ b/packages/db/prisma/schema/comment.prisma @@ -24,4 +24,5 @@ enum CommentEntityType { vendor risk policy + finding } From 08d314cfb9428bbf3bb9b54d194c2dd615fc6c72 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 12 May 2026 16:20:06 -0400 Subject: [PATCH 02/13] fix(api): update comment endpoint to support finding entityType --- apps/api/src/audit/audit-log.constants.ts | 1 + .../comment-mention-notifier.service.ts | 21 +++++++++++++++++++ apps/api/src/comments/comments.service.ts | 8 +++++++ 3 files changed, 30 insertions(+) diff --git a/apps/api/src/audit/audit-log.constants.ts b/apps/api/src/audit/audit-log.constants.ts index ce69e5fcdb..6a4c5fe544 100644 --- a/apps/api/src/audit/audit-log.constants.ts +++ b/apps/api/src/audit/audit-log.constants.ts @@ -61,6 +61,7 @@ export const COMMENT_ENTITY_TYPE_MAP: Record = { [CommentEntityType.vendor]: AuditLogEntityType.vendor, [CommentEntityType.risk]: AuditLogEntityType.risk, [CommentEntityType.policy]: AuditLogEntityType.policy, + [CommentEntityType.finding]: AuditLogEntityType.finding, }; // Fields that reference the member table and should be resolved to user names. diff --git a/apps/api/src/comments/comment-mention-notifier.service.ts b/apps/api/src/comments/comment-mention-notifier.service.ts index d4ce80a875..f15b641ab6 100644 --- a/apps/api/src/comments/comment-mention-notifier.service.ts +++ b/apps/api/src/comments/comment-mention-notifier.service.ts @@ -174,6 +174,27 @@ async function buildFallbackCommentContext(params: { }; } + if (entityType === CommentEntityType.finding) { + const finding = await db.finding.findFirst({ + where: { id: entityId, organizationId }, + select: { content: true }, + }); + + if (!finding) { + return null; + } + + const url = new URL(`${appUrl}/${organizationId}/overview/findings`); + url.searchParams.set('open', entityId); + + const snippet = finding.content?.trim().split('\n')[0]?.slice(0, 80); + return { + entityName: snippet || 'Finding', + entityRoutePath: 'overview/findings', + commentUrl: url.toString(), + }; + } + // CommentEntityType.policy const policy = await db.policy.findFirst({ where: { id: entityId, organizationId }, diff --git a/apps/api/src/comments/comments.service.ts b/apps/api/src/comments/comments.service.ts index 40f79a03d0..270d3d4911 100644 --- a/apps/api/src/comments/comments.service.ts +++ b/apps/api/src/comments/comments.service.ts @@ -120,6 +120,14 @@ export class CommentsService { break; } + case CommentEntityType.finding: { + const finding = await db.finding.findFirst({ + where: { id: entityId, organizationId }, + }); + entityExists = !!finding; + break; + } + default: throw new BadRequestException(`Unsupported entity type: ${entityType}`); } From b8b587425a9ec11f37caae2315c96f37a6df8f20 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 12 May 2026 16:20:30 -0400 Subject: [PATCH 03/13] feat(app): add comments directly to findings --- .../overview/components/FindingDetailSheet.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FindingDetailSheet.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FindingDetailSheet.tsx index d8a009940d..9335fa1df9 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/FindingDetailSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/FindingDetailSheet.tsx @@ -12,6 +12,7 @@ import { function capitalize(s: string) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; } +import { Comments } from '@/components/comments/Comments'; import { usePermissions } from '@/hooks/use-permissions'; import { useSession } from '@/utils/auth-client'; import { FindingSeverity, FindingStatus } from '@db'; @@ -409,6 +410,18 @@ export function FindingDetailSheet({ + + + Comments + + + + Activity From c8a91e05644cb256af7dc1e68b4e216258b78646 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 13 May 2026 15:25:19 -0400 Subject: [PATCH 04/13] fix(api): add an endpoint to delete the finding as platform admin --- .../admin-findings.controller.spec.ts | 23 +++++++++++++++++++ .../admin-findings.controller.ts | 11 +++++++++ apps/api/src/findings/findings.service.ts | 2 +- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/api/src/admin-organizations/admin-findings.controller.spec.ts b/apps/api/src/admin-organizations/admin-findings.controller.spec.ts index a96cb6dd03..ae1278457a 100644 --- a/apps/api/src/admin-organizations/admin-findings.controller.spec.ts +++ b/apps/api/src/admin-organizations/admin-findings.controller.spec.ts @@ -36,6 +36,7 @@ describe('AdminFindingsController', () => { findByOrganizationId: jest.fn(), create: jest.fn(), update: jest.fn(), + delete: jest.fn(), }; beforeEach(async () => { @@ -122,4 +123,26 @@ describe('AdminFindingsController', () => { expect(result).toEqual(updated); }); }); + + describe('remove', () => { + it('should delete a finding as platform admin', async () => { + const deleted = { + message: 'Finding deleted successfully', + deletedFinding: { id: 'fnd_1' }, + }; + mockService.delete.mockResolvedValue(deleted); + + const result = await controller.remove('org_1', 'fnd_1', { + userId: 'usr_admin', + }); + + expect(mockService.delete).toHaveBeenCalledWith( + 'org_1', + 'fnd_1', + 'usr_admin', + null, + ); + expect(result).toEqual(deleted); + }); + }); }); diff --git a/apps/api/src/admin-organizations/admin-findings.controller.ts b/apps/api/src/admin-organizations/admin-findings.controller.ts index 2598acc032..f7d6944751 100644 --- a/apps/api/src/admin-organizations/admin-findings.controller.ts +++ b/apps/api/src/admin-organizations/admin-findings.controller.ts @@ -1,5 +1,6 @@ import { Controller, + Delete, Get, Post, Patch, @@ -92,4 +93,14 @@ export class AdminFindingsController { null, ); } + + @Delete(':orgId/findings/:findingId') + @ApiOperation({ summary: 'Delete a finding for an organization (admin)' }) + async remove( + @Param('orgId') orgId: string, + @Param('findingId') findingId: string, + @Req() req: AdminRequest, + ) { + return this.findingsService.delete(orgId, findingId, req.userId, null); + } } diff --git a/apps/api/src/findings/findings.service.ts b/apps/api/src/findings/findings.service.ts index e5f1ff961c..371853bf85 100644 --- a/apps/api/src/findings/findings.service.ts +++ b/apps/api/src/findings/findings.service.ts @@ -486,7 +486,7 @@ export class FindingsService { organizationId: string, findingId: string, userId: string, - memberId: string, + memberId: string | null, ) { const finding = await this.findById(organizationId, findingId); From 7dac1e81f09a12b5d432da7b99a5b304017e00c4 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 13 May 2026 15:31:02 -0400 Subject: [PATCH 05/13] feat(app): add ability to edit/delete findings from admin dashboard --- .../components/AdminFindingRow.tsx | 168 +++++++++++++++ .../components/EditFindingSheet.tsx | 191 +++++++++++++++++ .../[adminOrgId]/components/FindingsTab.tsx | 195 ++++++++---------- 3 files changed, 440 insertions(+), 114 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminFindingRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/EditFindingSheet.tsx diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminFindingRow.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminFindingRow.tsx new file mode 100644 index 0000000000..6baee0f55a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminFindingRow.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + TableCell, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Edit, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; + +export interface AdminFinding { + id: string; + type: string; + status: string; + severity: string; + content: string; + area: string | null; + createdAt: string; + createdBy?: { user?: { name: string; email: string } } | null; + createdByAdmin?: { name: string; email: string } | null; + task?: { id: string; title: string } | null; + evidenceSubmission?: { id: string; formType: string } | null; + evidenceFormType?: string | null; + policy?: { id: string; name: string } | null; + vendor?: { id: string; name: string } | null; + risk?: { id: string; title: string } | null; + member?: { id: string; user: { name: string; email: string } } | null; + device?: { id: string; name: string; hostname: string } | null; +} + +const STATUS_OPTIONS = ['open', 'ready_for_review', 'needs_revision', 'closed']; + +const STATUS_VARIANT: Record = { + open: 'destructive', + ready_for_review: 'outline', + needs_revision: 'secondary', + closed: 'default', +}; + +const SEVERITY_VARIANT: Record = { + low: 'outline', + medium: 'secondary', + high: 'secondary', + critical: 'destructive', +}; + +function formatStatus(status: string) { + return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function getCreatorName(finding: AdminFinding): string { + return ( + finding.createdBy?.user?.name || + finding.createdBy?.user?.email || + finding.createdByAdmin?.name || + finding.createdByAdmin?.email || + 'Unknown' + ); +} + +export function getTargetLabel(f: AdminFinding): string { + if (f.task) return `Task: ${f.task.title}`; + if (f.policy) return `Policy: ${f.policy.name}`; + if (f.vendor) return `Vendor: ${f.vendor.name}`; + if (f.risk) return `Risk: ${f.risk.title}`; + if (f.member) return `Person: ${f.member.user.name || f.member.user.email}`; + if (f.device) return `Device: ${f.device.name || f.device.hostname}`; + if (f.evidenceSubmission) return `Evidence: ${f.evidenceSubmission.formType}`; + if (f.evidenceFormType) return `Form: ${f.evidenceFormType}`; + if (f.area) return `Area: ${f.area}`; + return '—'; +} + +interface AdminFindingRowProps { + finding: AdminFinding; + statusUpdating: boolean; + onStatusChange: (findingId: string, newStatus: string) => void; + onEdit: (finding: AdminFinding) => void; + onDelete: (finding: AdminFinding) => void; +} + +export function AdminFindingRow({ + finding, + statusUpdating, + onStatusChange, + onEdit, + onDelete, +}: AdminFindingRowProps) { + return ( + + +
+ {finding.content} +
+
+ + + {getTargetLabel(finding)} + + + + + {finding.severity} + + + + + {getCreatorName(finding)} + + + + + + +
+ + + + + + onEdit(finding)}> + + Edit + + onDelete(finding)} + > + + Delete + + + +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/EditFindingSheet.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/EditFindingSheet.tsx new file mode 100644 index 0000000000..f9f526ffd1 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/EditFindingSheet.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { api } from '@/lib/api-client'; +import { + Button, + HStack, + Select, + SelectContent, + SelectItem, + SelectTrigger, + Sheet, + SheetBody, + SheetContent, + SheetHeader, + SheetTitle, + Stack, + Text, + Textarea, +} from '@trycompai/design-system'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +export interface EditableFinding { + id: string; + content: string; + severity: string; + status: string; +} + +const STATUS_OPTIONS = ['open', 'ready_for_review', 'needs_revision', 'closed']; +const SEVERITY_OPTIONS = ['low', 'medium', 'high', 'critical']; + +function capitalize(s: string) { + return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; +} + +function formatStatus(status: string) { + return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +interface EditFindingSheetProps { + orgId: string; + finding: TFinding | null; + /** Label describing what the finding is logged against. */ + targetLabel: string; + onOpenChange: (open: boolean) => void; + onSaved: (updated: TFinding) => void; +} + +export function EditFindingSheet({ + orgId, + finding, + targetLabel, + onOpenChange, + onSaved, +}: EditFindingSheetProps) { + const [content, setContent] = useState(''); + const [severity, setSeverity] = useState('medium'); + const [status, setStatus] = useState('open'); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (finding) { + setContent(finding.content); + setSeverity(finding.severity); + setStatus(finding.status); + } + }, [finding]); + + if (!finding) return null; + + const isDirty = + content !== finding.content || + severity !== finding.severity || + status !== finding.status; + + const handleSave = async () => { + setSaving(true); + const payload: Record = {}; + if (content !== finding.content) payload.content = content; + if (severity !== finding.severity) payload.severity = severity; + if (status !== finding.status) payload.status = status; + + const res = await api.patch>( + `/v1/admin/organizations/${orgId}/findings/${finding.id}`, + payload, + ); + if (res.error) { + toast.error(res.error); + } else { + toast.success('Finding updated'); + onSaved({ ...finding, ...(res.data ?? {}), content, severity, status }); + } + setSaving(false); + }; + + return ( + + + + Edit finding + + + + + + Target + + + {targetLabel} + + + + + +