diff --git a/apps/api/src/trust-portal/dto/trust-custom-framework.dto.spec.ts b/apps/api/src/trust-portal/dto/trust-custom-framework.dto.spec.ts new file mode 100644 index 0000000000..75f7861913 --- /dev/null +++ b/apps/api/src/trust-portal/dto/trust-custom-framework.dto.spec.ts @@ -0,0 +1,53 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { UploadCustomFrameworkBadgeDto } from './trust-custom-framework.dto'; + +async function errorsFor(payload: Record): Promise { + const dto = plainToInstance(UploadCustomFrameworkBadgeDto, payload); + const errors = await validate(dto); + return errors.map((e) => e.property); +} + +const valid = { + customFrameworkId: 'cfrm_a', + fileName: 'badge.png', + fileType: 'image/png', + fileData: Buffer.from('hello').toString('base64'), // 'aGVsbG8=' +}; + +describe('UploadCustomFrameworkBadgeDto', () => { + it('accepts a well-formed payload', async () => { + expect(await errorsFor(valid)).toHaveLength(0); + }); + + it('rejects malformed (non-base64) fileData', async () => { + expect( + await errorsFor({ ...valid, fileData: 'not valid base64 @@@' }), + ).toContain('fileData'); + }); + + it('rejects fileData over the max length (oversized payload)', async () => { + // Valid base64 (length multiple of 4) but past the ~256KB MaxLength bound, + // so MaxLength — not IsBase64 — is what rejects it. + expect( + await errorsFor({ ...valid, fileData: 'A'.repeat(350_004) }), + ).toContain('fileData'); + }); + + it('rejects empty required fields', async () => { + const props = await errorsFor({ + customFrameworkId: '', + fileName: '', + fileType: '', + fileData: '', + }); + expect(props).toEqual( + expect.arrayContaining([ + 'customFrameworkId', + 'fileName', + 'fileType', + 'fileData', + ]), + ); + }); +}); diff --git a/apps/api/src/trust-portal/dto/trust-custom-framework.dto.ts b/apps/api/src/trust-portal/dto/trust-custom-framework.dto.ts index 38f792c8b0..8272f33dd4 100644 --- a/apps/api/src/trust-portal/dto/trust-custom-framework.dto.ts +++ b/apps/api/src/trust-portal/dto/trust-custom-framework.dto.ts @@ -1,3 +1,5 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBase64, IsNotEmpty, IsString, MaxLength } from 'class-validator'; import { z } from 'zod'; /** @@ -31,6 +33,8 @@ export interface TrustCustomFrameworkAdminItem { /** Whether a compliance certificate PDF has been uploaded. */ hasCertificate: boolean; certificateFileName: string | null; + /** Signed URL to the uploaded badge/logo, or null when none is set. */ + badgeUrl: string | null; } /** A custom framework as shown on the public portal. */ @@ -40,4 +44,67 @@ export interface TrustCustomFrameworkPublicItem { description: string; status: 'started' | 'in_progress' | 'compliant'; hasCertificate: boolean; + /** Signed URL to the uploaded badge/logo, or null when none is set. */ + badgeUrl: string | null; +} + +/** Upload (or replace) the badge/logo image for one custom framework. */ +export class UploadCustomFrameworkBadgeDto { + @ApiProperty({ + description: 'Org-authored custom framework ID the badge belongs to', + example: 'cfrm_6914cd0e16e4c7dccbb54426', + }) + @IsString() + @IsNotEmpty() + customFrameworkId!: string; + + @ApiProperty({ + description: 'Original file name (PNG, JPEG, or WebP)', + example: 'acme-framework-badge.png', + }) + @IsString() + @IsNotEmpty() + fileName!: string; + + @ApiProperty({ + description: 'MIME type of the image', + example: 'image/png', + }) + @IsString() + @IsNotEmpty() + fileType!: string; + + @ApiProperty({ + description: + 'Base64 encoded image content (PNG/JPEG/WebP, max 256KB decoded)', + }) + @IsString() + @IsNotEmpty() + @IsBase64() + // ~256KB once base64-decoded; rejects oversized/malformed payloads at the + // request boundary. The service enforces the exact decoded-byte cap. + @MaxLength(350_000) + fileData!: string; +} + +/** Query params for removing a custom framework's badge. */ +export class RemoveCustomFrameworkBadgeQueryDto { + @ApiProperty({ + description: 'Org-authored custom framework ID whose badge to remove', + example: 'cfrm_6914cd0e16e4c7dccbb54426', + }) + @IsString() + @IsNotEmpty() + customFrameworkId!: string; +} + +/** Response from a successful badge upload. */ +export class CustomFrameworkBadgeResponseDto { + @ApiProperty() + success!: boolean; + + @ApiProperty({ + description: 'Signed URL to the uploaded badge for immediate display', + }) + badgeUrl!: string; } diff --git a/apps/api/src/trust-portal/trust-custom-framework-badge.service.spec.ts b/apps/api/src/trust-portal/trust-custom-framework-badge.service.spec.ts new file mode 100644 index 0000000000..90b69482e2 --- /dev/null +++ b/apps/api/src/trust-portal/trust-custom-framework-badge.service.spec.ts @@ -0,0 +1,188 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { TrustCustomFrameworkBadgeService } from './trust-custom-framework-badge.service'; +import { getSignedUrl, s3Client } from '../app/s3'; + +jest.mock('@db', () => ({ + db: { + customFramework: { findFirst: jest.fn() }, + trustCustomFramework: { + upsert: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + }, +})); + +jest.mock('../app/s3', () => ({ + APP_AWS_ORG_ASSETS_BUCKET: 'org-assets', + s3Client: { send: jest.fn() }, + getSignedUrl: jest.fn(), +})); + +const mockDb = db as unknown as { + customFramework: { findFirst: jest.Mock }; + trustCustomFramework: { + upsert: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; +}; +const mockS3 = s3Client as unknown as { send: jest.Mock }; +const mockGetSignedUrl = getSignedUrl as unknown as jest.Mock; + +// "hello" -> 5 bytes: a valid, small, non-empty image payload for the happy path. +const SMALL_BASE64 = 'aGVsbG8='; + +describe('TrustCustomFrameworkBadgeService', () => { + let service: TrustCustomFrameworkBadgeService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new TrustCustomFrameworkBadgeService(); + }); + + describe('uploadBadge', () => { + const dto = { + customFrameworkId: 'cfrm_a', + fileName: 'badge.png', + fileType: 'image/png', + fileData: SMALL_BASE64, + }; + + it('throws NotFound when the framework is not in the org (tenant scoping)', async () => { + mockDb.customFramework.findFirst.mockResolvedValue(null); + + await expect( + service.uploadBadge('org_1', { ...dto, customFrameworkId: 'cfrm_x' }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(mockDb.customFramework.findFirst).toHaveBeenCalledWith({ + where: { id: 'cfrm_x', organizationId: 'org_1' }, + select: { id: true }, + }); + expect(mockS3.send).not.toHaveBeenCalled(); + }); + + it('rejects non-image types (e.g. SVG)', async () => { + mockDb.customFramework.findFirst.mockResolvedValue({ id: 'cfrm_a' }); + + await expect( + service.uploadBadge('org_1', { + ...dto, + fileName: 'badge.svg', + fileType: 'image/svg+xml', + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockS3.send).not.toHaveBeenCalled(); + }); + + it('rejects a disallowed MIME type even when the extension is allowed', async () => { + mockDb.customFramework.findFirst.mockResolvedValue({ id: 'cfrm_a' }); + + // A ".png" name must not let a non-image MIME through — the MIME is what we + // store as the S3 ContentType. + await expect( + service.uploadBadge('org_1', { + ...dto, + fileName: 'badge.png', + fileType: 'text/html', + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockS3.send).not.toHaveBeenCalled(); + }); + + it('rejects images larger than 256KB', async () => { + mockDb.customFramework.findFirst.mockResolvedValue({ id: 'cfrm_a' }); + // 'A' is valid base64; ~400KB of chars decodes to ~300KB > 256KB cap. + const oversized = 'A'.repeat(400 * 1024); + + await expect( + service.uploadBadge('org_1', { ...dto, fileData: oversized }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockS3.send).not.toHaveBeenCalled(); + }); + + it('uploads, stores the key without publishing, and returns a signed url', async () => { + mockDb.customFramework.findFirst.mockResolvedValue({ id: 'cfrm_a' }); + mockDb.trustCustomFramework.upsert.mockResolvedValue({}); + mockS3.send.mockResolvedValue({}); + mockGetSignedUrl.mockResolvedValue('https://signed/badge.png'); + + const result = await service.uploadBadge('org_1', dto); + + expect(result).toEqual({ + success: true, + badgeUrl: 'https://signed/badge.png', + }); + expect(mockS3.send).toHaveBeenCalledTimes(1); + + const upsertArg = mockDb.trustCustomFramework.upsert.mock.calls[0][0]; + expect(upsertArg.where).toEqual({ + organizationId_customFrameworkId: { + organizationId: 'org_1', + customFrameworkId: 'cfrm_a', + }, + }); + // First badge upload must NOT publish the framework. + expect(upsertArg.create).toMatchObject({ + organizationId: 'org_1', + customFrameworkId: 'cfrm_a', + enabled: false, + }); + expect(upsertArg.create.badgeS3Key).toContain( + 'org_1/trust/custom-framework/cfrm_a/badge/', + ); + // Replace path only touches the key. + expect(upsertArg.update).toEqual({ + badgeS3Key: upsertArg.create.badgeS3Key, + }); + }); + }); + + describe('removeBadge', () => { + it('throws NotFound when no selection exists', async () => { + mockDb.trustCustomFramework.findUnique.mockResolvedValue(null); + + await expect( + service.removeBadge('org_1', 'cfrm_a'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(mockDb.trustCustomFramework.update).not.toHaveBeenCalled(); + }); + + it('clears the stored badge key', async () => { + mockDb.trustCustomFramework.findUnique.mockResolvedValue({ + customFrameworkId: 'cfrm_a', + }); + mockDb.trustCustomFramework.update.mockResolvedValue({}); + + const result = await service.removeBadge('org_1', 'cfrm_a'); + + expect(result).toEqual({ success: true }); + expect(mockDb.trustCustomFramework.update).toHaveBeenCalledWith({ + where: { + organizationId_customFrameworkId: { + organizationId: 'org_1', + customFrameworkId: 'cfrm_a', + }, + }, + data: { badgeS3Key: null }, + }); + }); + }); + + describe('signBadgeUrl', () => { + it('returns a signed url', async () => { + mockGetSignedUrl.mockResolvedValue('https://signed/x.png'); + + await expect(service.signBadgeUrl('some/key.png')).resolves.toBe( + 'https://signed/x.png', + ); + }); + + it('returns null when signing fails (graceful fallback to initials)', async () => { + mockGetSignedUrl.mockRejectedValue(new Error('boom')); + + await expect(service.signBadgeUrl('some/key.png')).resolves.toBeNull(); + }); + }); +}); diff --git a/apps/api/src/trust-portal/trust-custom-framework-badge.service.ts b/apps/api/src/trust-portal/trust-custom-framework-badge.service.ts new file mode 100644 index 0000000000..349b7b9a10 --- /dev/null +++ b/apps/api/src/trust-portal/trust-custom-framework-badge.service.ts @@ -0,0 +1,169 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { db } from '@db'; +import { APP_AWS_ORG_ASSETS_BUCKET, getSignedUrl, s3Client } from '../app/s3'; +import type { UploadCustomFrameworkBadgeDto } from './dto/trust-custom-framework.dto'; + +// Badge images are shown on the public Trust Portal, so keep them small and +// non-executable. SVG is intentionally excluded (it can carry inline scripts). +// The MIME type is the security-relevant field: it's stored as the S3 object's +// ContentType, which is what the browser uses to interpret the file on render. +const ALLOWED_BADGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']; +const MAX_BADGE_BYTES = 256 * 1024; // 256KB +// Matches the favicon public-serve TTL (getFaviconSignedUrl). The public page +// re-fetches per render, so a fresh URL is signed each time. +const BADGE_SIGNED_URL_TTL_SECONDS = 86400; // 24 hours + +/** + * S3 mechanics for custom-framework badge/logo images on the Trust Portal: + * upload, remove, and signed-URL resolution. Split out of + * TrustCustomFrameworkService to keep that file focused on portal selection. + */ +@Injectable() +export class TrustCustomFrameworkBadgeService { + private readonly logger = new Logger(TrustCustomFrameworkBadgeService.name); + + /** + * Upload (or replace) the badge/logo image for one custom framework. Mirrors + * the favicon upload flow (base64 -> S3 -> signed URL). Uploading a badge is a + * presentation-only action: on first write it does NOT publish the framework + * (create defaults `enabled: false`), so visibility stays controlled solely by + * the enable toggle. + */ + async uploadBadge( + organizationId: string, + dto: UploadCustomFrameworkBadgeDto, + ): Promise<{ success: true; badgeUrl: string }> { + const client = s3Client; + const bucket = APP_AWS_ORG_ASSETS_BUCKET; + if (!client || !bucket) { + throw new ServiceUnavailableException( + 'Organization assets bucket is not configured', + ); + } + + const { customFrameworkId, fileName, fileType, fileData } = dto; + + // Tenant check: the custom framework must belong to this org. Also satisfies + // the composite FK (customFrameworkId, organizationId) -> CustomFramework. + const customFramework = await db.customFramework.findFirst({ + where: { id: customFrameworkId, organizationId }, + select: { id: true }, + }); + if (!customFramework) { + throw new NotFoundException('Custom framework not found'); + } + + // Reject on the MIME type alone — a disallowed type must never pass just + // because the filename happens to carry an allowed extension. + if (!ALLOWED_BADGE_TYPES.includes(fileType)) { + throw new BadRequestException('Badge must be a PNG, JPEG, or WebP image'); + } + + const fileBuffer = Buffer.from(fileData, 'base64'); + if (fileBuffer.length === 0) { + throw new BadRequestException('Invalid image data'); + } + if (fileBuffer.length > MAX_BADGE_BYTES) { + throw new BadRequestException('Badge must be less than 256KB'); + } + + const timestamp = Date.now(); + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + const key = `${organizationId}/trust/custom-framework/${customFrameworkId}/badge/${timestamp}-${sanitizedFileName}`; + + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: fileBuffer, + ContentType: fileType, + CacheControl: 'public, max-age=31536000, immutable', + }), + ); + + await db.trustCustomFramework.upsert({ + where: { + organizationId_customFrameworkId: { organizationId, customFrameworkId }, + }, + // Don't publish on first badge upload — visibility is the toggle's job. + create: { + organizationId, + customFrameworkId, + enabled: false, + badgeS3Key: key, + }, + update: { badgeS3Key: key }, + }); + + const signedUrl = await getSignedUrl( + client, + new GetObjectCommand({ Bucket: bucket, Key: key }), + { expiresIn: BADGE_SIGNED_URL_TTL_SECONDS }, + ); + + this.logger.log( + `Uploaded trust portal badge for custom framework ${customFrameworkId} (org ${organizationId})`, + ); + + return { success: true, badgeUrl: signedUrl }; + } + + /** + * Remove a custom framework's badge. Clears the stored key only (the S3 object + * is left in place, matching removeFavicon); the portal falls back to the + * initials avatar. + */ + async removeBadge( + organizationId: string, + customFrameworkId: string, + ): Promise<{ success: true }> { + const selection = await db.trustCustomFramework.findUnique({ + where: { + organizationId_customFrameworkId: { organizationId, customFrameworkId }, + }, + select: { customFrameworkId: true }, + }); + if (!selection) { + throw new NotFoundException('Custom framework selection not found'); + } + + await db.trustCustomFramework.update({ + where: { + organizationId_customFrameworkId: { organizationId, customFrameworkId }, + }, + data: { badgeS3Key: null }, + }); + + return { success: true }; + } + + /** + * Resolve a stored badge S3 key to a temporary signed URL. Returns null on any + * failure (or when S3 isn't configured) so read paths degrade gracefully to + * the initials avatar — mirrors getFaviconSignedUrl. + */ + async signBadgeUrl(badgeS3Key: string): Promise { + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { + return null; + } + try { + return await getSignedUrl( + s3Client, + new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: badgeS3Key, + }), + { expiresIn: BADGE_SIGNED_URL_TTL_SECONDS }, + ); + } catch { + return null; + } + } +} diff --git a/apps/api/src/trust-portal/trust-custom-framework.service.spec.ts b/apps/api/src/trust-portal/trust-custom-framework.service.spec.ts index 3c0e243c74..b9086a89cf 100644 --- a/apps/api/src/trust-portal/trust-custom-framework.service.spec.ts +++ b/apps/api/src/trust-portal/trust-custom-framework.service.spec.ts @@ -1,6 +1,7 @@ import { NotFoundException } from '@nestjs/common'; import { db } from '@db'; import { TrustCustomFrameworkService } from './trust-custom-framework.service'; +import type { TrustCustomFrameworkBadgeService } from './trust-custom-framework-badge.service'; jest.mock('@db', () => ({ db: { @@ -30,10 +31,15 @@ const mockDb = db as unknown as { describe('TrustCustomFrameworkService', () => { let service: TrustCustomFrameworkService; + let badgeService: { signBadgeUrl: jest.Mock }; beforeEach(() => { jest.clearAllMocks(); - service = new TrustCustomFrameworkService(); + // Read paths only depend on signBadgeUrl; default to "no badge" (null). + badgeService = { signBadgeUrl: jest.fn().mockResolvedValue(null) }; + service = new TrustCustomFrameworkService( + badgeService as unknown as TrustCustomFrameworkBadgeService, + ); }); describe('listForOrg', () => { @@ -60,6 +66,7 @@ describe('TrustCustomFrameworkService', () => { status: 'compliant', hasCertificate: true, certificateFileName: 'acme.pdf', + badgeUrl: null, }, { // Never configured for the portal -> disabled / started / no cert. @@ -70,6 +77,7 @@ describe('TrustCustomFrameworkService', () => { status: 'started', hasCertificate: false, certificateFileName: null, + badgeUrl: null, }, ]); expect(mockDb.customFramework.findMany).toHaveBeenCalledWith({ @@ -86,6 +94,29 @@ describe('TrustCustomFrameworkService', () => { await expect(service.listForOrg('org_1')).resolves.toEqual([]); }); + + it('resolves a signed badgeUrl when a badge key is stored', async () => { + mockDb.customFramework.findMany.mockResolvedValue([ + { id: 'cfrm_a', name: 'Acme Std', description: 'Internal' }, + ]); + mockDb.trustCustomFramework.findMany.mockResolvedValue([ + { + customFrameworkId: 'cfrm_a', + enabled: true, + status: 'compliant', + badgeS3Key: 'org_1/trust/custom-framework/cfrm_a/badge/1-logo.png', + }, + ]); + mockDb.trustResource.findMany.mockResolvedValue([]); + badgeService.signBadgeUrl.mockResolvedValue('https://signed/badge.png'); + + const result = await service.listForOrg('org_1'); + + expect(result[0].badgeUrl).toBe('https://signed/badge.png'); + expect(badgeService.signBadgeUrl).toHaveBeenCalledWith( + 'org_1/trust/custom-framework/cfrm_a/badge/1-logo.png', + ); + }); }); describe('updateSelection', () => { @@ -173,12 +204,14 @@ describe('TrustCustomFrameworkService', () => { description: 'x', status: 'compliant', hasCertificate: true, + badgeUrl: null, }, ]); expect(mockDb.trustCustomFramework.findMany).toHaveBeenCalledWith({ where: { organizationId: 'org_1', enabled: true }, select: { status: true, + badgeS3Key: true, customFramework: { select: { id: true, name: true, description: true }, }, diff --git a/apps/api/src/trust-portal/trust-custom-framework.service.ts b/apps/api/src/trust-portal/trust-custom-framework.service.ts index daeaaa2f47..fead86bc33 100644 --- a/apps/api/src/trust-portal/trust-custom-framework.service.ts +++ b/apps/api/src/trust-portal/trust-custom-framework.service.ts @@ -5,6 +5,7 @@ import type { TrustCustomFrameworkPublicItem, UpdateTrustCustomFrameworkDto, } from './dto/trust-custom-framework.dto'; +import { TrustCustomFrameworkBadgeService } from './trust-custom-framework-badge.service'; /** * Manages which org-authored custom frameworks are displayed on the public @@ -20,6 +21,10 @@ import type { export class TrustCustomFrameworkService { private readonly logger = new Logger(TrustCustomFrameworkService.name); + constructor( + private readonly badgeService: TrustCustomFrameworkBadgeService, + ) {} + /** * List every custom framework the org owns, joined with its Trust Portal * selection state and whether a certificate has been uploaded. Frameworks the @@ -36,7 +41,12 @@ export class TrustCustomFrameworkService { }), db.trustCustomFramework.findMany({ where: { organizationId }, - select: { customFrameworkId: true, enabled: true, status: true }, + select: { + customFrameworkId: true, + enabled: true, + status: true, + badgeS3Key: true, + }, }), db.trustResource.findMany({ where: { organizationId, customFrameworkId: { not: null } }, @@ -53,20 +63,25 @@ export class TrustCustomFrameworkService { .map((c) => [c.customFrameworkId as string, c.fileName]), ); - return customFrameworks.map((framework) => { - const selection = selectionByFramework.get(framework.id); - const certificateFileName = - certificateByFramework.get(framework.id) ?? null; - return { - customFrameworkId: framework.id, - name: framework.name, - description: framework.description, - enabled: selection?.enabled ?? false, - status: selection?.status ?? 'started', - hasCertificate: certificateFileName !== null, - certificateFileName, - }; - }); + return Promise.all( + customFrameworks.map(async (framework) => { + const selection = selectionByFramework.get(framework.id); + const certificateFileName = + certificateByFramework.get(framework.id) ?? null; + return { + customFrameworkId: framework.id, + name: framework.name, + description: framework.description, + enabled: selection?.enabled ?? false, + status: selection?.status ?? 'started', + hasCertificate: certificateFileName !== null, + certificateFileName, + badgeUrl: selection?.badgeS3Key + ? await this.badgeService.signBadgeUrl(selection.badgeS3Key) + : null, + }; + }), + ); } /** @@ -129,6 +144,7 @@ export class TrustCustomFrameworkService { where: { organizationId, enabled: true }, select: { status: true, + badgeS3Key: true, customFramework: { select: { id: true, name: true, description: true }, }, @@ -148,13 +164,18 @@ export class TrustCustomFrameworkService { certificates.map((c) => c.customFrameworkId), ); - return selections.map((selection) => ({ - id: selection.customFramework.id, - name: selection.customFramework.name, - description: selection.customFramework.description, - status: selection.status, - hasCertificate: withCertificate.has(selection.customFramework.id), - })); + return Promise.all( + selections.map(async (selection) => ({ + id: selection.customFramework.id, + name: selection.customFramework.name, + description: selection.customFramework.description, + status: selection.status, + hasCertificate: withCertificate.has(selection.customFramework.id), + badgeUrl: selection.badgeS3Key + ? await this.badgeService.signBadgeUrl(selection.badgeS3Key) + : null, + })), + ); } private async resolveOrganizationId( diff --git a/apps/api/src/trust-portal/trust-portal.controller.spec.ts b/apps/api/src/trust-portal/trust-portal.controller.spec.ts index 5dbd0bc1da..8917d0d9e1 100644 --- a/apps/api/src/trust-portal/trust-portal.controller.spec.ts +++ b/apps/api/src/trust-portal/trust-portal.controller.spec.ts @@ -3,6 +3,7 @@ import { BadRequestException } from '@nestjs/common'; import { TrustPortalController } from './trust-portal.controller'; import { TrustPortalService } from './trust-portal.service'; import { TrustCustomFrameworkService } from './trust-custom-framework.service'; +import { TrustCustomFrameworkBadgeService } from './trust-custom-framework-badge.service'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; import type { AuthContext as AuthContextType } from '../auth/types'; @@ -59,6 +60,12 @@ describe('TrustPortalController', () => { getPublicCustomFrameworks: jest.fn(), }; + const mockBadgeService = { + uploadBadge: jest.fn(), + removeBadge: jest.fn(), + signBadgeUrl: jest.fn(), + }; + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; const orgId = 'org_test123'; @@ -85,6 +92,10 @@ describe('TrustPortalController', () => { provide: TrustCustomFrameworkService, useValue: mockCustomFrameworkService, }, + { + provide: TrustCustomFrameworkBadgeService, + useValue: mockBadgeService, + }, ], }) .overrideGuard(HybridAuthGuard) @@ -673,4 +684,41 @@ describe('TrustPortalController', () => { expect(service.getAllVendorsWithSync).not.toHaveBeenCalled(); }); }); + + describe('uploadCustomFrameworkBadge', () => { + it('delegates to badgeService.uploadBadge with org + dto', async () => { + const dto = { + customFrameworkId: 'cfrm_a', + fileName: 'badge.png', + fileType: 'image/png', + fileData: 'base64data', + }; + const mockResult = { + success: true, + badgeUrl: 'https://signed/badge.png', + }; + mockBadgeService.uploadBadge.mockResolvedValue(mockResult); + + const result = await controller.uploadCustomFrameworkBadge(orgId, dto); + + expect(result).toEqual(mockResult); + expect(mockBadgeService.uploadBadge).toHaveBeenCalledWith(orgId, dto); + }); + }); + + describe('removeCustomFrameworkBadge', () => { + it('delegates to badgeService.removeBadge with org + customFrameworkId', async () => { + mockBadgeService.removeBadge.mockResolvedValue({ success: true }); + + const result = await controller.removeCustomFrameworkBadge(orgId, { + customFrameworkId: 'cfrm_a', + }); + + expect(result).toEqual({ success: true }); + expect(mockBadgeService.removeBadge).toHaveBeenCalledWith( + orgId, + 'cfrm_a', + ); + }); + }); }); diff --git a/apps/api/src/trust-portal/trust-portal.controller.ts b/apps/api/src/trust-portal/trust-portal.controller.ts index 56f0d92dbe..40747e7de7 100644 --- a/apps/api/src/trust-portal/trust-portal.controller.ts +++ b/apps/api/src/trust-portal/trust-portal.controller.ts @@ -62,10 +62,14 @@ import { UpdateVendorTrustSettingsSchema } from './dto/trust-vendor.dto'; import { UpdateTrustCustomFrameworkSchema, type UpdateTrustCustomFrameworkDto, + UploadCustomFrameworkBadgeDto, + RemoveCustomFrameworkBadgeQueryDto, + CustomFrameworkBadgeResponseDto, } from './dto/trust-custom-framework.dto'; import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; import { TrustPortalService } from './trust-portal.service'; import { TrustCustomFrameworkService } from './trust-custom-framework.service'; +import { TrustCustomFrameworkBadgeService } from './trust-custom-framework-badge.service'; class ListComplianceResourcesDto { @ApiProperty({ @@ -84,6 +88,7 @@ export class TrustPortalController { constructor( private readonly trustPortalService: TrustPortalService, private readonly trustCustomFrameworkService: TrustCustomFrameworkService, + private readonly trustCustomFrameworkBadgeService: TrustCustomFrameworkBadgeService, ) {} @Get('settings') @@ -464,6 +469,51 @@ export class TrustPortalController { ); } + @Post('custom-frameworks/badge') + @HttpCode(HttpStatus.CREATED) + @RequirePermission('trust', 'update') + @ApiOperation({ + summary: "Upload or replace a custom framework's Trust Portal badge image", + description: + "Stores a PNG/JPEG/WebP badge (max 256KB) in the organization assets bucket. Does not change the framework's portal visibility.", + }) + @ApiBody({ type: UploadCustomFrameworkBadgeDto }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Badge uploaded successfully', + type: CustomFrameworkBadgeResponseDto, + }) + async uploadCustomFrameworkBadge( + @OrganizationId() organizationId: string, + @Body() dto: UploadCustomFrameworkBadgeDto, + ): Promise { + return this.trustCustomFrameworkBadgeService.uploadBadge( + organizationId, + dto, + ); + } + + @Delete('custom-frameworks/badge') + @HttpCode(HttpStatus.OK) + @RequirePermission('trust', 'update') + @ApiOperation({ + summary: "Remove a custom framework's Trust Portal badge image", + }) + @ApiQuery({ + name: 'customFrameworkId', + description: 'Org-authored custom framework ID whose badge to remove', + required: true, + }) + async removeCustomFrameworkBadge( + @OrganizationId() organizationId: string, + @Query() query: RemoveCustomFrameworkBadgeQueryDto, + ) { + return this.trustCustomFrameworkBadgeService.removeBadge( + organizationId, + query.customFrameworkId, + ); + } + @Post('overview') @HttpCode(HttpStatus.OK) @RequirePermission('trust', 'update') diff --git a/apps/api/src/trust-portal/trust-portal.module.ts b/apps/api/src/trust-portal/trust-portal.module.ts index bc783eb4ec..6a1b8d69b6 100644 --- a/apps/api/src/trust-portal/trust-portal.module.ts +++ b/apps/api/src/trust-portal/trust-portal.module.ts @@ -9,6 +9,7 @@ import { TrustAccessService } from './trust-access.service'; import { TrustPortalController } from './trust-portal.controller'; import { TrustPortalService } from './trust-portal.service'; import { TrustCustomFrameworkService } from './trust-custom-framework.service'; +import { TrustCustomFrameworkBadgeService } from './trust-custom-framework-badge.service'; @Module({ imports: [AuthModule, AttachmentsModule], @@ -16,11 +17,16 @@ import { TrustCustomFrameworkService } from './trust-custom-framework.service'; providers: [ TrustPortalService, TrustCustomFrameworkService, + TrustCustomFrameworkBadgeService, TrustAccessService, NdaPdfService, TrustEmailService, PolicyPdfRendererService, ], - exports: [TrustPortalService, TrustCustomFrameworkService, TrustAccessService], + exports: [ + TrustPortalService, + TrustCustomFrameworkService, + TrustAccessService, + ], }) export class TrustPortalModule {} diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/ComplianceFramework.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/ComplianceFramework.tsx index 71b96ad685..e422d63359 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/ComplianceFramework.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/ComplianceFramework.tsx @@ -19,6 +19,7 @@ import { import { CertificateCheck, Download, Upload, View } from '@trycompai/design-system/icons'; import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; +import { CustomFrameworkBadge } from './CustomFrameworkBadge'; import { CCPA, CCPAInProgress, @@ -122,6 +123,10 @@ export function ComplianceFramework({ fileName, onFileUpload, onFilePreview, + isCustomFramework, + badgeUrl, + onBadgeUpload, + onBadgeRemove, frameworkKey, orgId, disabled, @@ -135,6 +140,14 @@ export function ComplianceFramework({ fileName?: string | null; onFileUpload?: (file: File, frameworkKey: string) => Promise; onFilePreview?: (frameworkKey: string) => Promise; + // Badge/logo — only for custom frameworks (native ones use their built-in SVG + // logo). `isCustomFramework` swaps the logo slot for the uploadable badge so a + // read-only viewer still SEES the badge; the upload/remove controls only + // appear when the corresponding handler is provided and the row is editable. + isCustomFramework?: boolean; + badgeUrl?: string | null; + onBadgeUpload?: (file: File, frameworkKey: string) => Promise; + onBadgeRemove?: (frameworkKey: string) => Promise; frameworkKey: string; orgId: string; disabled?: boolean; @@ -235,7 +248,17 @@ export function ComplianceFramework({
- + {isCustomFramework ? ( + onBadgeUpload(file, frameworkKey) : undefined} + onRemove={onBadgeRemove ? () => onBadgeRemove(frameworkKey) : undefined} + disabled={disabled} + /> + ) : ( + + )}
{title} diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworkBadge.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworkBadge.tsx new file mode 100644 index 0000000000..3a4198d243 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworkBadge.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { TrashCan, Upload } from '@trycompai/design-system/icons'; +import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +const ALLOWED_BADGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']; +const MAX_BADGE_BYTES = 256 * 1024; // 256KB — mirrors the API cap. + +function getFrameworkInitials(title: string): string { + const words = title.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return '?'; + if (words.length === 1) return words[0].slice(0, 2); + return (words[0][0] + words[1][0]).slice(0, 2); +} + +/** + * The logo slot for a custom framework on the Trust Portal admin page. Shows the + * uploaded badge image when present, otherwise an initials avatar (matching the + * public portal fallback). When `onUpload` is provided and not disabled, the slot + * doubles as an uploader (click to pick / replace, with a remove button). + */ +export function CustomFrameworkBadge({ + title, + badgeUrl, + onUpload, + onRemove, + disabled, +}: { + title: string; + badgeUrl?: string | null; + onUpload?: (file: File) => Promise; + onRemove?: () => Promise; + disabled?: boolean; +}) { + const [isBusy, setIsBusy] = useState(false); + // Fall back to initials if the badge image fails to load (e.g. an expired + // signed URL). Reset when the URL changes so a freshly uploaded/replaced + // badge gets a fresh attempt. + const [imgErrored, setImgErrored] = useState(false); + useEffect(() => setImgErrored(false), [badgeUrl]); + const fileInputRef = useRef(null); + const editable = !!onUpload && !disabled; + + const handleFile = async (file: File) => { + // Match the server: gate on the MIME type (it becomes the stored ContentType). + if (!ALLOWED_BADGE_TYPES.includes(file.type)) { + toast.error('Badge must be a PNG, JPEG, or WebP image'); + return; + } + if (file.size > MAX_BADGE_BYTES) { + toast.error('Badge must be less than 256KB'); + return; + } + if (!onUpload) return; + setIsBusy(true); + try { + await onUpload(file); + toast.success('Badge updated'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to upload badge'); + } finally { + setIsBusy(false); + } + }; + + const handleRemove = async () => { + if (!onRemove) return; + setIsBusy(true); + try { + await onRemove(); + toast.success('Badge removed'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to remove badge'); + } finally { + setIsBusy(false); + } + }; + + const inner = + badgeUrl && !imgErrored ? ( + {`${title} setImgErrored(true)} + /> + ) : ( +
+ {getFrameworkInitials(title)} +
+ ); + + if (!editable) { + return
{inner}
; + } + + return ( +
+ { + const file = e.target.files?.[0]; + // Reset so re-selecting the same file still fires onChange. + e.target.value = ''; + if (file) await handleFile(file); + }} + /> + + {badgeUrl && !isBusy && ( + + )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworksSection.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworksSection.test.tsx index be292ebb54..e4438f209a 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworksSection.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworksSection.test.tsx @@ -1,17 +1,21 @@ import type { TrustCustomFrameworkItem } from '@/hooks/use-trust-portal-settings'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CustomFrameworksSection } from './CustomFrameworksSection'; const updateCustomFramework = vi.fn(); const uploadCustomComplianceResource = vi.fn(); const getCustomComplianceResourceUrl = vi.fn(); +const uploadCustomFrameworkBadge = vi.fn(); +const removeCustomFrameworkBadge = vi.fn(); vi.mock('@/hooks/use-trust-portal-settings', () => ({ useTrustPortalSettings: () => ({ updateCustomFramework, uploadCustomComplianceResource, getCustomComplianceResourceUrl, + uploadCustomFrameworkBadge, + removeCustomFrameworkBadge, }), })); @@ -28,6 +32,7 @@ const frameworks: TrustCustomFrameworkItem[] = [ status: 'compliant', hasCertificate: false, certificateFileName: null, + badgeUrl: null, }, { customFrameworkId: 'cfrm_b', @@ -37,6 +42,7 @@ const frameworks: TrustCustomFrameworkItem[] = [ status: 'started', hasCertificate: false, certificateFileName: null, + badgeUrl: null, }, ]; @@ -84,4 +90,71 @@ describe('CustomFrameworksSection', () => { expect(switches.length).toBeGreaterThan(0); switches.forEach((toggle) => expect(toggle).toHaveAttribute('aria-disabled', 'true')); }); + + it('shows a badge uploader on each framework for admins', () => { + render( + , + ); + + // One upload affordance per framework (neither has a badge yet). + expect(screen.getAllByLabelText('Upload badge')).toHaveLength(2); + }); + + it('hides the badge uploader for read-only users', () => { + render( + , + ); + + expect(screen.queryByLabelText('Upload badge')).toBeNull(); + expect(screen.queryByLabelText('Replace badge')).toBeNull(); + expect(screen.queryByLabelText('Remove badge')).toBeNull(); + }); + + it('renders an uploaded badge image with a remove control for admins', () => { + render( + , + ); + + expect(screen.getByAltText('Acme Internal Standard badge')).toBeInTheDocument(); + expect(screen.getByLabelText('Replace badge')).toBeInTheDocument(); + expect(screen.getByLabelText('Remove badge')).toBeInTheDocument(); + }); + + it('falls back to initials when the badge image fails to load', () => { + render( + , + ); + + const img = screen.getByAltText('Acme Internal Standard badge'); + fireEvent.error(img); + + // Broken/expired image -> initials avatar ("AI" from "Acme Internal"). + expect(screen.queryByAltText('Acme Internal Standard badge')).toBeNull(); + expect(screen.getByText('AI')).toBeInTheDocument(); + }); + + it('still shows an uploaded badge to read-only users (without controls)', () => { + render( + , + ); + + expect(screen.getByAltText('Acme Internal Standard badge')).toBeInTheDocument(); + expect(screen.queryByLabelText('Remove badge')).toBeNull(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworksSection.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworksSection.tsx index 9a55668802..09ef021d45 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworksSection.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/CustomFrameworksSection.tsx @@ -36,8 +36,13 @@ export function CustomFrameworksSection({ canUpdate: boolean; initialCustomFrameworks: TrustCustomFrameworkItem[]; }) { - const { updateCustomFramework, uploadCustomComplianceResource, getCustomComplianceResourceUrl } = - useTrustPortalSettings(); + const { + updateCustomFramework, + uploadCustomComplianceResource, + getCustomComplianceResourceUrl, + uploadCustomFrameworkBadge, + removeCustomFrameworkBadge, + } = useTrustPortalSettings(); const [frameworks, setFrameworks] = useState(initialCustomFrameworks); @@ -123,6 +128,30 @@ export function CustomFrameworksSection({ window.open(payload.signedUrl, '_blank', 'noopener,noreferrer'); }; + const setBadgeUrl = (customFrameworkId: string, badgeUrl: string | null) => { + setFrameworks((prev) => + prev.map((framework) => + framework.customFrameworkId === customFrameworkId ? { ...framework, badgeUrl } : framework, + ), + ); + }; + + const handleBadgeUpload = async (file: File, customFrameworkId: string) => { + const fileData = await convertFileToBase64(file); + const payload = await uploadCustomFrameworkBadge( + customFrameworkId, + file.name, + file.type || 'image/png', + fileData, + ); + setBadgeUrl(customFrameworkId, payload.badgeUrl); + }; + + const handleBadgeRemove = async (customFrameworkId: string) => { + await removeCustomFrameworkBadge(customFrameworkId); + setBadgeUrl(customFrameworkId, null); + }; + return (
@@ -168,6 +197,10 @@ export function CustomFrameworksSection({ }} onFileUpload={canUpdate ? handleFileUpload : undefined} onFilePreview={handleFilePreview} + isCustomFramework + badgeUrl={framework.badgeUrl} + onBadgeUpload={canUpdate ? handleBadgeUpload : undefined} + onBadgeRemove={canUpdate ? handleBadgeRemove : undefined} /> ))}
diff --git a/apps/app/src/hooks/use-trust-portal-settings.ts b/apps/app/src/hooks/use-trust-portal-settings.ts index b82951ec1c..c8ba67ece7 100644 --- a/apps/app/src/hooks/use-trust-portal-settings.ts +++ b/apps/app/src/hooks/use-trust-portal-settings.ts @@ -51,6 +51,13 @@ export interface TrustCustomFrameworkItem { status: 'started' | 'in_progress' | 'compliant'; hasCertificate: boolean; certificateFileName: string | null; + /** Signed URL to the uploaded badge/logo, or null when none is set. */ + badgeUrl: string | null; +} + +interface CustomFrameworkBadgeResponse { + success: boolean; + badgeUrl: string; } interface UpdateCustomFrameworkData { @@ -161,6 +168,30 @@ export function useTrustPortalSettings() { [api], ); + const uploadCustomFrameworkBadge = useCallback( + async (customFrameworkId: string, fileName: string, fileType: string, fileData: string) => { + const response = await api.post( + '/v1/trust-portal/custom-frameworks/badge', + { customFrameworkId, fileName, fileType, fileData }, + ); + if (response.error) throw new Error(response.error); + if (!response.data) throw new Error('Unexpected API response'); + return response.data; + }, + [api], + ); + + const removeCustomFrameworkBadge = useCallback( + async (customFrameworkId: string) => { + const response = await api.delete( + `/v1/trust-portal/custom-frameworks/badge?customFrameworkId=${encodeURIComponent(customFrameworkId)}`, + ); + if (response.error) throw new Error(response.error); + return response.data; + }, + [api], + ); + const saveOverview = useCallback( async (data: OverviewData) => { const response = await api.post('/v1/trust-portal/overview', data); @@ -255,6 +286,8 @@ export function useTrustPortalSettings() { updateCustomFramework, uploadCustomComplianceResource, getCustomComplianceResourceUrl, + uploadCustomFrameworkBadge, + removeCustomFrameworkBadge, saveOverview, updateVendorTrustSettings, updateAllowedDomains, diff --git a/apps/app/src/trigger/tasks/onboarding/generate-risk-mitigation.test.ts b/apps/app/src/trigger/tasks/onboarding/generate-risk-mitigation.test.ts new file mode 100644 index 0000000000..33da1568ab --- /dev/null +++ b/apps/app/src/trigger/tasks/onboarding/generate-risk-mitigation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; + +// generate-risk-mitigation.ts calls task()/queue() at import time and pulls in +// the Prisma client, axios, and onboarding helpers. Mock those so we can import +// and unit-test the pure buildMitigationDefaultWrites helper in isolation. +vi.mock('@trigger.dev/sdk', () => ({ + task: vi.fn((config) => config), + queue: vi.fn((config) => config), + tags: { add: vi.fn() }, + metadata: { set: vi.fn(), increment: vi.fn(), decrement: vi.fn() }, + tasks: { trigger: vi.fn(), batchTriggerAndWait: vi.fn() }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); +vi.mock('@db/server', () => ({ + db: {}, + Prisma: {}, + RiskStatus: { open: 'open', pending: 'pending', closed: 'closed', archived: 'archived' }, +})); +vi.mock('axios', () => ({ default: { post: vi.fn() } })); +vi.mock('./onboard-organization-helpers', () => ({ + createRiskMitigationComment: vi.fn(), + findCommentAuthor: vi.fn(), +})); + +import { buildMitigationDefaultWrites } from './generate-risk-mitigation'; + +describe('buildMitigationDefaultWrites', () => { + const riskId = 'rsk_1'; + const organizationId = 'org_1'; + + it('promotes a still-open risk to pending, scoped to status: open', () => { + const writes = buildMitigationDefaultWrites({ riskId, organizationId }); + + expect(writes).toHaveLength(1); + expect(writes[0]).toEqual({ + where: { id: riskId, organizationId, status: 'open' }, + data: { status: 'pending' }, + }); + }); + + it('never issues an unconditional status write — the where-clause must constrain status to open so an async re-run (Regenerate / task-unlink) cannot reopen a user-closed risk', () => { + const writes = buildMitigationDefaultWrites({ riskId, organizationId, authorId: 'mem_1' }); + + for (const write of writes) { + const data = write.data as { status?: string }; + if (data.status === 'pending') { + expect(write.where).toMatchObject({ status: 'open' }); + } + } + }); + + it('assigns the author only when the risk is still unassigned', () => { + const writes = buildMitigationDefaultWrites({ riskId, organizationId, authorId: 'mem_1' }); + + expect(writes).toHaveLength(2); + expect(writes[1]).toEqual({ + where: { id: riskId, organizationId, assigneeId: null }, + data: { assigneeId: 'mem_1' }, + }); + }); + + it('skips the assignee write entirely on the task-unlink path (no author)', () => { + const writes = buildMitigationDefaultWrites({ riskId, organizationId }); + + expect(writes.some((w) => 'assigneeId' in (w.data as object))).toBe(false); + }); +}); diff --git a/apps/app/src/trigger/tasks/onboarding/generate-risk-mitigation.ts b/apps/app/src/trigger/tasks/onboarding/generate-risk-mitigation.ts index 475bc5ebe8..2a5e379a37 100644 --- a/apps/app/src/trigger/tasks/onboarding/generate-risk-mitigation.ts +++ b/apps/app/src/trigger/tasks/onboarding/generate-risk-mitigation.ts @@ -1,4 +1,4 @@ -import { RiskStatus, db } from '@db/server'; +import { Prisma, RiskStatus, db } from '@db/server'; import { logger, metadata, queue, tags, task, tasks } from '@trigger.dev/sdk'; import axios from 'axios'; import { @@ -11,6 +11,48 @@ import { const riskMitigationQueue = queue({ name: 'risk-mitigations', concurrencyLimit: 50 }); const riskMitigationFanoutQueue = queue({ name: 'risk-mitigations-fanout', concurrencyLimit: 50 }); +/** + * Builds the "apply onboarding defaults" writes for a risk after a mitigation + * plan is drafted, WITHOUT clobbering a user-managed risk. + * + * generateRiskMitigation re-runs on EXISTING risks — via the "Regenerate" + * button and via task-unlink (refreshTreatmentPlan) — and those runs land + * asynchronously AFTER the user may have changed the risk. An unconditional + * write here silently reopened risks the user had closed ("I mark a risk + * closed and it comes back as pending"). So each write is scoped: + * - status: promote only a still-default `open` risk to `pending` (the AI + * drafted a plan that needs review). Never downgrade a user-set + * pending/closed/archived. + * - assigneeId: assign the author only when the risk is still unassigned — + * don't reassign a risk the user has already given an owner. + * + * The status/assignee where-clauses also keep this correct against a race + * where the user edits the risk while this async job is in flight. + */ +export function buildMitigationDefaultWrites(params: { + riskId: string; + organizationId: string; + authorId?: string; +}): Prisma.RiskUpdateManyArgs[] { + const { riskId, organizationId, authorId } = params; + + const writes: Prisma.RiskUpdateManyArgs[] = [ + { + where: { id: riskId, organizationId, status: RiskStatus.open }, + data: { status: RiskStatus.pending }, + }, + ]; + + if (authorId) { + writes.push({ + where: { id: riskId, organizationId, assigneeId: null }, + data: { assigneeId: authorId }, + }); + } + + return writes; +} + export const generateRiskMitigation = task({ id: 'generate-risk-mitigation', queue: riskMitigationQueue, @@ -42,17 +84,16 @@ export const generateRiskMitigation = task({ await createRiskMitigationComment(risk, policies, organizationId, authorId ?? ''); - // Mark risk as PENDING (not closed) — the AI drafted a plan but the - // user still needs to review it. Closing on the user's behalf would - // skip review and feel automated-away. Reassign to owner/admin only - // if we have one. - await db.risk.update({ - where: { id: risk.id, organizationId }, - data: { - status: RiskStatus.pending, - ...(authorId ? { assigneeId: authorId } : {}), - }, - }); + // Apply onboarding defaults without clobbering a user-managed risk — the + // AI drafted a plan, but the user owns the status/assignee. See + // buildMitigationDefaultWrites for why each write is scoped. + for (const write of buildMitigationDefaultWrites({ + riskId: risk.id, + organizationId, + authorId, + })) { + await db.risk.updateMany(write); + } // Mark as completed after mitigation is done // Update root onboarding task metadata if available diff --git a/packages/db/prisma/migrations/20260617000000_trust_custom_framework_badge/migration.sql b/packages/db/prisma/migrations/20260617000000_trust_custom_framework_badge/migration.sql new file mode 100644 index 0000000000..3e981737bc --- /dev/null +++ b/packages/db/prisma/migrations/20260617000000_trust_custom_framework_badge/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TrustCustomFramework" ADD COLUMN "badgeS3Key" TEXT; diff --git a/packages/db/prisma/schema/trust.prisma b/packages/db/prisma/schema/trust.prisma index 850a00ae72..8d2bb70c06 100644 --- a/packages/db/prisma/schema/trust.prisma +++ b/packages/db/prisma/schema/trust.prisma @@ -136,6 +136,11 @@ model TrustCustomFramework { enabled Boolean @default(true) status FrameworkStatus @default(started) + // Optional uploaded badge/logo image for this custom framework, shown on the + // public Trust Portal in place of the initials avatar. Stores an S3 object + // KEY (not a URL), resolved to a signed URL on read — mirrors `Trust.favicon`. + badgeS3Key String? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt