diff --git a/apps/api/prisma/migrations/20260301194500_add_findings/migration.sql b/apps/api/prisma/migrations/20260301194500_add_findings/migration.sql new file mode 100644 index 0000000..92c1b9b --- /dev/null +++ b/apps/api/prisma/migrations/20260301194500_add_findings/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE "findings" ( + "id" UUID NOT NULL, + "workspace_id" UUID NOT NULL, + "task_id" UUID, + "agent_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "data" JSONB NOT NULL, + "summary" TEXT NOT NULL, + "embedding" vector(1536), + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL, + + CONSTRAINT "findings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "findings_id_workspace_id_key" ON "findings"("id", "workspace_id"); + +-- CreateIndex +CREATE INDEX "findings_workspace_id_idx" ON "findings"("workspace_id"); + +-- CreateIndex +CREATE INDEX "findings_agent_id_idx" ON "findings"("agent_id"); + +-- CreateIndex +CREATE INDEX "findings_type_idx" ON "findings"("type"); + +-- CreateIndex +CREATE INDEX "findings_task_id_idx" ON "findings"("task_id"); + +-- AddForeignKey +ALTER TABLE "findings" ADD CONSTRAINT "findings_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "findings" ADD CONSTRAINT "findings_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "agent_tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b63af21..5550ff5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -298,6 +298,7 @@ model Workspace { agents Agent[] agentSessions AgentSession[] agentTasks AgentTask[] + findings Finding[] userLayouts UserLayout[] knowledgeEntries KnowledgeEntry[] knowledgeTags KnowledgeTag[] @@ -689,6 +690,7 @@ model AgentTask { createdBy User @relation("AgentTaskCreator", fields: [createdById], references: [id], onDelete: Cascade) createdById String @map("created_by_id") @db.Uuid runnerJobs RunnerJob[] + findings Finding[] @@unique([id, workspaceId]) @@index([workspaceId]) @@ -698,6 +700,33 @@ model AgentTask { @@map("agent_tasks") } +model Finding { + id String @id @default(uuid()) @db.Uuid + workspaceId String @map("workspace_id") @db.Uuid + taskId String? @map("task_id") @db.Uuid + + agentId String @map("agent_id") + type String + title String + data Json + summary String @db.Text + // Note: vector dimension (1536) must match EMBEDDING_DIMENSION constant in @mosaic/shared + embedding Unsupported("vector(1536)")? + + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + task AgentTask? @relation(fields: [taskId], references: [id], onDelete: SetNull) + + @@unique([id, workspaceId]) + @@index([workspaceId]) + @@index([agentId]) + @@index([type]) + @@index([taskId]) + @@map("findings") +} + model AgentSession { id String @id @default(uuid()) @db.Uuid workspaceId String @map("workspace_id") @db.Uuid diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a9df914..8b27292 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -27,6 +27,7 @@ import { LlmUsageModule } from "./llm-usage/llm-usage.module"; import { BrainModule } from "./brain/brain.module"; import { CronModule } from "./cron/cron.module"; import { AgentTasksModule } from "./agent-tasks/agent-tasks.module"; +import { FindingsModule } from "./findings/findings.module"; import { ValkeyModule } from "./valkey/valkey.module"; import { BullMqModule } from "./bullmq/bullmq.module"; import { StitcherModule } from "./stitcher/stitcher.module"; @@ -100,6 +101,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce BrainModule, CronModule, AgentTasksModule, + FindingsModule, RunnerJobsModule, JobEventsModule, JobStepsModule, diff --git a/apps/api/src/findings/dto/create-finding.dto.ts b/apps/api/src/findings/dto/create-finding.dto.ts new file mode 100644 index 0000000..d59e6dc --- /dev/null +++ b/apps/api/src/findings/dto/create-finding.dto.ts @@ -0,0 +1,33 @@ +import { IsObject, IsOptional, IsString, IsUUID, MaxLength, MinLength } from "class-validator"; + +/** + * DTO for creating a finding + */ +export class CreateFindingDto { + @IsOptional() + @IsUUID("4", { message: "taskId must be a valid UUID" }) + taskId?: string; + + @IsString({ message: "agentId must be a string" }) + @MinLength(1, { message: "agentId must not be empty" }) + @MaxLength(255, { message: "agentId must not exceed 255 characters" }) + agentId!: string; + + @IsString({ message: "type must be a string" }) + @MinLength(1, { message: "type must not be empty" }) + @MaxLength(100, { message: "type must not exceed 100 characters" }) + type!: string; + + @IsString({ message: "title must be a string" }) + @MinLength(1, { message: "title must not be empty" }) + @MaxLength(255, { message: "title must not exceed 255 characters" }) + title!: string; + + @IsObject({ message: "data must be an object" }) + data!: Record; + + @IsString({ message: "summary must be a string" }) + @MinLength(1, { message: "summary must not be empty" }) + @MaxLength(20000, { message: "summary must not exceed 20000 characters" }) + summary!: string; +} diff --git a/apps/api/src/findings/dto/index.ts b/apps/api/src/findings/dto/index.ts new file mode 100644 index 0000000..12bc324 --- /dev/null +++ b/apps/api/src/findings/dto/index.ts @@ -0,0 +1,3 @@ +export { CreateFindingDto } from "./create-finding.dto"; +export { QueryFindingsDto } from "./query-findings.dto"; +export { SearchFindingsDto } from "./search-findings.dto"; diff --git a/apps/api/src/findings/dto/query-findings.dto.ts b/apps/api/src/findings/dto/query-findings.dto.ts new file mode 100644 index 0000000..c577109 --- /dev/null +++ b/apps/api/src/findings/dto/query-findings.dto.ts @@ -0,0 +1,32 @@ +import { Type } from "class-transformer"; +import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from "class-validator"; + +/** + * DTO for querying findings with filters and pagination + */ +export class QueryFindingsDto { + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; + + @IsOptional() + @IsString({ message: "agentId must be a string" }) + agentId?: string; + + @IsOptional() + @IsString({ message: "type must be a string" }) + type?: string; + + @IsOptional() + @IsUUID("4", { message: "taskId must be a valid UUID" }) + taskId?: string; +} diff --git a/apps/api/src/findings/dto/search-findings.dto.ts b/apps/api/src/findings/dto/search-findings.dto.ts new file mode 100644 index 0000000..854fc4e --- /dev/null +++ b/apps/api/src/findings/dto/search-findings.dto.ts @@ -0,0 +1,52 @@ +import { Type } from "class-transformer"; +import { + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, +} from "class-validator"; + +/** + * DTO for finding semantic similarity search + */ +export class SearchFindingsDto { + @IsString({ message: "query must be a string" }) + @MaxLength(1000, { message: "query must not exceed 1000 characters" }) + query!: string; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({}, { message: "similarityThreshold must be a number" }) + @Min(0, { message: "similarityThreshold must be at least 0" }) + @Max(1, { message: "similarityThreshold must not exceed 1" }) + similarityThreshold?: number; + + @IsOptional() + @IsString({ message: "agentId must be a string" }) + agentId?: string; + + @IsOptional() + @IsString({ message: "type must be a string" }) + type?: string; + + @IsOptional() + @IsUUID("4", { message: "taskId must be a valid UUID" }) + taskId?: string; +} diff --git a/apps/api/src/findings/findings.controller.spec.ts b/apps/api/src/findings/findings.controller.spec.ts new file mode 100644 index 0000000..69908b4 --- /dev/null +++ b/apps/api/src/findings/findings.controller.spec.ts @@ -0,0 +1,195 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { FindingsController } from "./findings.controller"; +import { FindingsService } from "./findings.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { CreateFindingDto, QueryFindingsDto, SearchFindingsDto } from "./dto"; + +describe("FindingsController", () => { + let controller: FindingsController; + let service: FindingsService; + + const mockFindingsService = { + create: vi.fn(), + findAll: vi.fn(), + findOne: vi.fn(), + search: vi.fn(), + remove: vi.fn(), + }; + + const mockAuthGuard = { + canActivate: vi.fn(() => true), + }; + + const mockWorkspaceGuard = { + canActivate: vi.fn(() => true), + }; + + const mockPermissionGuard = { + canActivate: vi.fn(() => true), + }; + + const workspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const findingId = "550e8400-e29b-41d4-a716-446655440002"; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FindingsController], + providers: [ + { + provide: FindingsService, + useValue: mockFindingsService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue(mockAuthGuard) + .overrideGuard(WorkspaceGuard) + .useValue(mockWorkspaceGuard) + .overrideGuard(PermissionGuard) + .useValue(mockPermissionGuard) + .compile(); + + controller = module.get(FindingsController); + service = module.get(FindingsService); + + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + describe("create", () => { + it("should create a finding", async () => { + const createDto: CreateFindingDto = { + agentId: "research-agent", + type: "security", + title: "SQL injection risk", + data: { severity: "high" }, + summary: "Potential SQL injection in search endpoint.", + }; + + const createdFinding = { + id: findingId, + workspaceId, + taskId: null, + ...createDto, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockFindingsService.create.mockResolvedValue(createdFinding); + + const result = await controller.create(createDto, workspaceId); + + expect(result).toEqual(createdFinding); + expect(service.create).toHaveBeenCalledWith(workspaceId, createDto); + }); + }); + + describe("findAll", () => { + it("should return paginated findings", async () => { + const query: QueryFindingsDto = { + page: 1, + limit: 10, + type: "security", + }; + + const response = { + data: [], + meta: { + total: 0, + page: 1, + limit: 10, + totalPages: 0, + }, + }; + + mockFindingsService.findAll.mockResolvedValue(response); + + const result = await controller.findAll(query, workspaceId); + + expect(result).toEqual(response); + expect(service.findAll).toHaveBeenCalledWith(workspaceId, query); + }); + }); + + describe("findOne", () => { + it("should return a finding", async () => { + const finding = { + id: findingId, + workspaceId, + taskId: null, + agentId: "research-agent", + type: "security", + title: "SQL injection risk", + data: { severity: "high" }, + summary: "Potential SQL injection in search endpoint.", + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockFindingsService.findOne.mockResolvedValue(finding); + + const result = await controller.findOne(findingId, workspaceId); + + expect(result).toEqual(finding); + expect(service.findOne).toHaveBeenCalledWith(findingId, workspaceId); + }); + }); + + describe("search", () => { + it("should perform semantic search", async () => { + const searchDto: SearchFindingsDto = { + query: "sql injection", + limit: 5, + }; + + const response = { + data: [ + { + id: findingId, + workspaceId, + taskId: null, + agentId: "research-agent", + type: "security", + title: "SQL injection risk", + data: { severity: "high" }, + summary: "Potential SQL injection in search endpoint.", + createdAt: new Date(), + updatedAt: new Date(), + score: 0.91, + }, + ], + meta: { + total: 1, + page: 1, + limit: 5, + totalPages: 1, + }, + query: "sql injection", + }; + + mockFindingsService.search.mockResolvedValue(response); + + const result = await controller.search(searchDto, workspaceId); + + expect(result).toEqual(response); + expect(service.search).toHaveBeenCalledWith(workspaceId, searchDto); + }); + }); + + describe("remove", () => { + it("should delete a finding", async () => { + const response = { message: "Finding deleted successfully" }; + mockFindingsService.remove.mockResolvedValue(response); + + const result = await controller.remove(findingId, workspaceId); + + expect(result).toEqual(response); + expect(service.remove).toHaveBeenCalledWith(findingId, workspaceId); + }); + }); +}); diff --git a/apps/api/src/findings/findings.controller.ts b/apps/api/src/findings/findings.controller.ts new file mode 100644 index 0000000..e034626 --- /dev/null +++ b/apps/api/src/findings/findings.controller.ts @@ -0,0 +1,81 @@ +import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from "@nestjs/common"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, Permission, RequirePermission } from "../common/decorators"; +import { CreateFindingDto, QueryFindingsDto, SearchFindingsDto } from "./dto"; +import { + FindingsService, + FindingsSearchResponse, + PaginatedFindingsResponse, +} from "./findings.service"; + +/** + * Controller for findings endpoints + * All endpoints require authentication and workspace context + */ +@Controller("findings") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class FindingsController { + constructor(private readonly findingsService: FindingsService) {} + + /** + * POST /api/findings + * Create a new finding and embed its summary + * Requires: MEMBER role or higher + */ + @Post() + @RequirePermission(Permission.WORKSPACE_MEMBER) + async create(@Body() createFindingDto: CreateFindingDto, @Workspace() workspaceId: string) { + return this.findingsService.create(workspaceId, createFindingDto); + } + + /** + * GET /api/findings + * Get paginated findings with optional filters + * Requires: Any workspace member + */ + @Get() + @RequirePermission(Permission.WORKSPACE_ANY) + async findAll( + @Query() query: QueryFindingsDto, + @Workspace() workspaceId: string + ): Promise { + return this.findingsService.findAll(workspaceId, query); + } + + /** + * GET /api/findings/:id + * Get a single finding by ID + * Requires: Any workspace member + */ + @Get(":id") + @RequirePermission(Permission.WORKSPACE_ANY) + async findOne(@Param("id") id: string, @Workspace() workspaceId: string) { + return this.findingsService.findOne(id, workspaceId); + } + + /** + * POST /api/findings/search + * Semantic search findings by vector similarity + * Requires: Any workspace member + */ + @Post("search") + @RequirePermission(Permission.WORKSPACE_ANY) + async search( + @Body() searchDto: SearchFindingsDto, + @Workspace() workspaceId: string + ): Promise { + return this.findingsService.search(workspaceId, searchDto); + } + + /** + * DELETE /api/findings/:id + * Delete a finding + * Requires: ADMIN role or higher + */ + @Delete(":id") + @RequirePermission(Permission.WORKSPACE_ADMIN) + async remove(@Param("id") id: string, @Workspace() workspaceId: string) { + return this.findingsService.remove(id, workspaceId); + } +} diff --git a/apps/api/src/findings/findings.module.ts b/apps/api/src/findings/findings.module.ts new file mode 100644 index 0000000..d3ce24c --- /dev/null +++ b/apps/api/src/findings/findings.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; +import { KnowledgeModule } from "../knowledge/knowledge.module"; +import { FindingsController } from "./findings.controller"; +import { FindingsService } from "./findings.service"; + +@Module({ + imports: [PrismaModule, AuthModule, KnowledgeModule], + controllers: [FindingsController], + providers: [FindingsService], + exports: [FindingsService], +}) +export class FindingsModule {} diff --git a/apps/api/src/findings/findings.service.spec.ts b/apps/api/src/findings/findings.service.spec.ts new file mode 100644 index 0000000..bd9c936 --- /dev/null +++ b/apps/api/src/findings/findings.service.spec.ts @@ -0,0 +1,300 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { FindingsService } from "./findings.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { EmbeddingService } from "../knowledge/services/embedding.service"; + +describe("FindingsService", () => { + let service: FindingsService; + let prisma: PrismaService; + let embeddingService: EmbeddingService; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const mockFindingId = "550e8400-e29b-41d4-a716-446655440002"; + const mockTaskId = "550e8400-e29b-41d4-a716-446655440003"; + + const mockPrismaService = { + finding: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + count: vi.fn(), + delete: vi.fn(), + }, + agentTask: { + findUnique: vi.fn(), + }, + $queryRaw: vi.fn(), + $executeRaw: vi.fn(), + }; + + const mockEmbeddingService = { + isConfigured: vi.fn(), + generateEmbedding: vi.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FindingsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: EmbeddingService, + useValue: mockEmbeddingService, + }, + ], + }).compile(); + + service = module.get(FindingsService); + prisma = module.get(PrismaService); + embeddingService = module.get(EmbeddingService); + + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("create", () => { + it("should create a finding and store embedding when configured", async () => { + const createDto = { + taskId: mockTaskId, + agentId: "research-agent", + type: "security", + title: "SQL injection risk", + data: { severity: "high" }, + summary: "Potential SQL injection in search endpoint.", + }; + + const createdFinding = { + id: mockFindingId, + workspaceId: mockWorkspaceId, + ...createDto, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.agentTask.findUnique.mockResolvedValue({ + id: mockTaskId, + workspaceId: mockWorkspaceId, + }); + mockPrismaService.finding.create.mockResolvedValue(createdFinding); + mockPrismaService.finding.findUnique.mockResolvedValue(createdFinding); + mockEmbeddingService.isConfigured.mockReturnValue(true); + mockEmbeddingService.generateEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + mockPrismaService.$executeRaw.mockResolvedValue(1); + + const result = await service.create(mockWorkspaceId, createDto); + + expect(result).toEqual(createdFinding); + expect(prisma.finding.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + workspaceId: mockWorkspaceId, + taskId: mockTaskId, + agentId: "research-agent", + type: "security", + title: "SQL injection risk", + }), + select: expect.any(Object), + }); + expect(embeddingService.generateEmbedding).toHaveBeenCalledWith(createDto.summary); + expect(prisma.$executeRaw).toHaveBeenCalled(); + }); + + it("should create a finding without embedding when not configured", async () => { + const createDto = { + agentId: "research-agent", + type: "security", + title: "SQL injection risk", + data: { severity: "high" }, + summary: "Potential SQL injection in search endpoint.", + }; + + const createdFinding = { + id: mockFindingId, + workspaceId: mockWorkspaceId, + taskId: null, + ...createDto, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.finding.create.mockResolvedValue(createdFinding); + mockEmbeddingService.isConfigured.mockReturnValue(false); + + const result = await service.create(mockWorkspaceId, createDto); + + expect(result).toEqual(createdFinding); + expect(embeddingService.generateEmbedding).not.toHaveBeenCalled(); + expect(prisma.$executeRaw).not.toHaveBeenCalled(); + }); + }); + + describe("findAll", () => { + it("should return paginated findings with filters", async () => { + const findings = [ + { + id: mockFindingId, + workspaceId: mockWorkspaceId, + taskId: null, + agentId: "research-agent", + type: "security", + title: "SQL injection risk", + data: { severity: "high" }, + summary: "Potential SQL injection in search endpoint.", + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockPrismaService.finding.findMany.mockResolvedValue(findings); + mockPrismaService.finding.count.mockResolvedValue(1); + + const result = await service.findAll(mockWorkspaceId, { + page: 1, + limit: 10, + type: "security", + agentId: "research-agent", + }); + + expect(result).toEqual({ + data: findings, + meta: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }); + expect(prisma.finding.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + workspaceId: mockWorkspaceId, + type: "security", + agentId: "research-agent", + }, + }) + ); + }); + }); + + describe("findOne", () => { + it("should return a finding", async () => { + const finding = { + id: mockFindingId, + workspaceId: mockWorkspaceId, + taskId: null, + agentId: "research-agent", + type: "security", + title: "SQL injection risk", + data: { severity: "high" }, + summary: "Potential SQL injection in search endpoint.", + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.finding.findUnique.mockResolvedValue(finding); + + const result = await service.findOne(mockFindingId, mockWorkspaceId); + + expect(result).toEqual(finding); + expect(prisma.finding.findUnique).toHaveBeenCalledWith({ + where: { + id: mockFindingId, + workspaceId: mockWorkspaceId, + }, + select: expect.any(Object), + }); + }); + + it("should throw when finding does not exist", async () => { + mockPrismaService.finding.findUnique.mockResolvedValue(null); + + await expect(service.findOne(mockFindingId, mockWorkspaceId)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe("search", () => { + it("should throw BadRequestException when embeddings are not configured", async () => { + mockEmbeddingService.isConfigured.mockReturnValue(false); + + await expect( + service.search(mockWorkspaceId, { + query: "sql injection", + }) + ).rejects.toThrow(BadRequestException); + }); + + it("should return similarity-ranked search results", async () => { + mockEmbeddingService.isConfigured.mockReturnValue(true); + mockEmbeddingService.generateEmbedding.mockResolvedValue([0.1, 0.2, 0.3]); + mockPrismaService.$queryRaw + .mockResolvedValueOnce([ + { + id: mockFindingId, + workspace_id: mockWorkspaceId, + task_id: null, + agent_id: "research-agent", + type: "security", + title: "SQL injection risk", + data: { severity: "high" }, + summary: "Potential SQL injection in search endpoint.", + created_at: new Date(), + updated_at: new Date(), + score: 0.91, + }, + ]) + .mockResolvedValueOnce([{ count: BigInt(1) }]); + + const result = await service.search(mockWorkspaceId, { + query: "sql injection", + page: 1, + limit: 5, + similarityThreshold: 0.5, + }); + + expect(result.query).toBe("sql injection"); + expect(result.data).toHaveLength(1); + expect(result.data[0].score).toBe(0.91); + expect(result.meta.total).toBe(1); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); + }); + }); + + describe("remove", () => { + it("should delete a finding", async () => { + mockPrismaService.finding.findUnique.mockResolvedValue({ + id: mockFindingId, + workspaceId: mockWorkspaceId, + }); + mockPrismaService.finding.delete.mockResolvedValue({ + id: mockFindingId, + }); + + const result = await service.remove(mockFindingId, mockWorkspaceId); + + expect(result).toEqual({ message: "Finding deleted successfully" }); + expect(prisma.finding.delete).toHaveBeenCalledWith({ + where: { + id: mockFindingId, + workspaceId: mockWorkspaceId, + }, + }); + }); + + it("should throw when finding does not exist", async () => { + mockPrismaService.finding.findUnique.mockResolvedValue(null); + + await expect(service.remove(mockFindingId, mockWorkspaceId)).rejects.toThrow( + NotFoundException + ); + }); + }); +}); diff --git a/apps/api/src/findings/findings.service.ts b/apps/api/src/findings/findings.service.ts new file mode 100644 index 0000000..e3ec681 --- /dev/null +++ b/apps/api/src/findings/findings.service.ts @@ -0,0 +1,337 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { EmbeddingService } from "../knowledge/services/embedding.service"; +import type { CreateFindingDto, QueryFindingsDto, SearchFindingsDto } from "./dto"; + +const findingSelect = { + id: true, + workspaceId: true, + taskId: true, + agentId: true, + type: true, + title: true, + data: true, + summary: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.FindingSelect; + +type FindingRecord = Prisma.FindingGetPayload<{ select: typeof findingSelect }>; + +interface RawFindingSearchResult { + id: string; + workspace_id: string; + task_id: string | null; + agent_id: string; + type: string; + title: string; + data: Prisma.JsonValue; + summary: string; + created_at: Date; + updated_at: Date; + score: number; +} + +export interface FindingSearchResult extends FindingRecord { + score: number; +} + +interface PaginatedMeta { + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface PaginatedFindingsResponse { + data: FindingRecord[]; + meta: PaginatedMeta; +} + +export interface FindingsSearchResponse { + data: FindingSearchResult[]; + meta: PaginatedMeta; + query: string; + similarityThreshold: number; +} + +/** + * Service for managing structured findings with vector search support + */ +@Injectable() +export class FindingsService { + private readonly logger = new Logger(FindingsService.name); + private readonly defaultSimilarityThreshold: number; + + constructor( + private readonly prisma: PrismaService, + private readonly embeddingService: EmbeddingService + ) { + const parsedThreshold = Number.parseFloat(process.env.FINDINGS_SIMILARITY_THRESHOLD ?? "0.5"); + + this.defaultSimilarityThreshold = + Number.isFinite(parsedThreshold) && parsedThreshold >= 0 && parsedThreshold <= 1 + ? parsedThreshold + : 0.5; + } + + /** + * Create a finding and generate its embedding from the summary when available + */ + async create(workspaceId: string, createFindingDto: CreateFindingDto): Promise { + if (createFindingDto.taskId) { + const task = await this.prisma.agentTask.findUnique({ + where: { + id: createFindingDto.taskId, + workspaceId, + }, + select: { id: true }, + }); + + if (!task) { + throw new NotFoundException(`Agent task with ID ${createFindingDto.taskId} not found`); + } + } + + const createInput: Prisma.FindingUncheckedCreateInput = { + workspaceId, + agentId: createFindingDto.agentId, + type: createFindingDto.type, + title: createFindingDto.title, + data: createFindingDto.data as Prisma.InputJsonValue, + summary: createFindingDto.summary, + }; + + if (createFindingDto.taskId) { + createInput.taskId = createFindingDto.taskId; + } + + const finding = await this.prisma.finding.create({ + data: createInput, + select: findingSelect, + }); + + await this.generateAndStoreEmbedding(finding.id, workspaceId, finding.summary); + + if (this.embeddingService.isConfigured()) { + return this.findOne(finding.id, workspaceId); + } + + return finding; + } + + /** + * Get paginated findings with optional filters + */ + async findAll(workspaceId: string, query: QueryFindingsDto): Promise { + const page = query.page ?? 1; + const limit = query.limit ?? 50; + const skip = (page - 1) * limit; + + const where: Prisma.FindingWhereInput = { + workspaceId, + }; + + if (query.agentId) { + where.agentId = query.agentId; + } + + if (query.type) { + where.type = query.type; + } + + if (query.taskId) { + where.taskId = query.taskId; + } + + const [data, total] = await Promise.all([ + this.prisma.finding.findMany({ + where, + select: findingSelect, + orderBy: { + createdAt: "desc", + }, + skip, + take: limit, + }), + this.prisma.finding.count({ where }), + ]); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Get a single finding by ID + */ + async findOne(id: string, workspaceId: string): Promise { + const finding = await this.prisma.finding.findUnique({ + where: { + id, + workspaceId, + }, + select: findingSelect, + }); + + if (!finding) { + throw new NotFoundException(`Finding with ID ${id} not found`); + } + + return finding; + } + + /** + * Semantic search findings using vector similarity + */ + async search(workspaceId: string, searchDto: SearchFindingsDto): Promise { + if (!this.embeddingService.isConfigured()) { + throw new BadRequestException( + "Finding vector search requires OPENAI_API_KEY to be configured" + ); + } + + const page = searchDto.page ?? 1; + const limit = searchDto.limit ?? 20; + const offset = (page - 1) * limit; + const similarityThreshold = searchDto.similarityThreshold ?? this.defaultSimilarityThreshold; + const distanceThreshold = 1 - similarityThreshold; + + const queryEmbedding = await this.embeddingService.generateEmbedding(searchDto.query); + const embeddingString = `[${queryEmbedding.join(",")}]`; + + const agentFilter = searchDto.agentId + ? Prisma.sql`AND f.agent_id = ${searchDto.agentId}` + : Prisma.sql``; + const typeFilter = searchDto.type ? Prisma.sql`AND f.type = ${searchDto.type}` : Prisma.sql``; + const taskFilter = searchDto.taskId + ? Prisma.sql`AND f.task_id = ${searchDto.taskId}::uuid` + : Prisma.sql``; + + const searchResults = await this.prisma.$queryRaw` + SELECT + f.id, + f.workspace_id, + f.task_id, + f.agent_id, + f.type, + f.title, + f.data, + f.summary, + f.created_at, + f.updated_at, + (1 - (f.embedding <=> ${embeddingString}::vector)) AS score + FROM findings f + WHERE f.workspace_id = ${workspaceId}::uuid + AND f.embedding IS NOT NULL + ${agentFilter} + ${typeFilter} + ${taskFilter} + AND (f.embedding <=> ${embeddingString}::vector) <= ${distanceThreshold} + ORDER BY f.embedding <=> ${embeddingString}::vector + LIMIT ${limit} + OFFSET ${offset} + `; + + const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(*) as count + FROM findings f + WHERE f.workspace_id = ${workspaceId}::uuid + AND f.embedding IS NOT NULL + ${agentFilter} + ${typeFilter} + ${taskFilter} + AND (f.embedding <=> ${embeddingString}::vector) <= ${distanceThreshold} + `; + + const total = Number(countResult[0].count); + + const data: FindingSearchResult[] = searchResults.map((row) => ({ + id: row.id, + workspaceId: row.workspace_id, + taskId: row.task_id, + agentId: row.agent_id, + type: row.type, + title: row.title, + data: row.data, + summary: row.summary, + createdAt: row.created_at, + updatedAt: row.updated_at, + score: row.score, + })); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + query: searchDto.query, + similarityThreshold, + }; + } + + /** + * Delete a finding + */ + async remove(id: string, workspaceId: string): Promise<{ message: string }> { + const existingFinding = await this.prisma.finding.findUnique({ + where: { + id, + workspaceId, + }, + select: { id: true }, + }); + + if (!existingFinding) { + throw new NotFoundException(`Finding with ID ${id} not found`); + } + + await this.prisma.finding.delete({ + where: { + id, + workspaceId, + }, + }); + + return { message: "Finding deleted successfully" }; + } + + /** + * Generate and persist embedding for a finding summary + */ + private async generateAndStoreEmbedding( + findingId: string, + workspaceId: string, + summary: string + ): Promise { + if (!this.embeddingService.isConfigured()) { + return; + } + + try { + const embedding = await this.embeddingService.generateEmbedding(summary); + const embeddingString = `[${embedding.join(",")}]`; + + await this.prisma.$executeRaw` + UPDATE findings + SET embedding = ${embeddingString}::vector, + updated_at = NOW() + WHERE id = ${findingId}::uuid + AND workspace_id = ${workspaceId}::uuid + `; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to generate embedding for finding ${findingId}: ${message}`); + } + } +}