Compare commits
6 Commits
feat/ms21-
...
de37e7be90
| Author | SHA1 | Date | |
|---|---|---|---|
| de37e7be90 | |||
| 7b390d8be2 | |||
| e8502577b8 | |||
| af68f84dcd | |||
| b57f549d39 | |||
| 2c8d0a8daf |
@@ -34,3 +34,9 @@ CVE-2026-26996 # HIGH: minimatch DoS via specially crafted glob patterns (needs
|
|||||||
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.
|
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.
|
||||||
# Cannot build OpenBao from source (large project). Waiting for upstream release.
|
# Cannot build OpenBao from source (large project). Waiting for upstream release.
|
||||||
CVE-2025-68121 # CRITICAL: crypto/tls session resumption
|
CVE-2025-68121 # CRITICAL: crypto/tls session resumption
|
||||||
|
|
||||||
|
# === multer CVEs (upstream via @nestjs/platform-express) ===
|
||||||
|
# multer <2.1.0 — waiting on NestJS to update their dependency
|
||||||
|
# These are DoS vulnerabilities in file upload handling
|
||||||
|
GHSA-xf7r-hgr6-v32p # HIGH: DoS via incomplete cleanup
|
||||||
|
GHSA-v52c-386h-88mc # HIGH: DoS via resource exhaustion
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "conversation_archives" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workspace_id" UUID NOT NULL,
|
||||||
|
"session_id" TEXT NOT NULL,
|
||||||
|
"agent_id" TEXT NOT NULL,
|
||||||
|
"messages" JSONB NOT NULL,
|
||||||
|
"message_count" INTEGER NOT NULL,
|
||||||
|
"summary" TEXT NOT NULL,
|
||||||
|
"embedding" vector(1536),
|
||||||
|
"started_at" TIMESTAMPTZ NOT NULL,
|
||||||
|
"ended_at" TIMESTAMPTZ,
|
||||||
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "conversation_archives_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "conversation_archives_workspace_id_session_id_key" ON "conversation_archives"("workspace_id", "session_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "conversation_archives_workspace_id_idx" ON "conversation_archives"("workspace_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "conversation_archives_agent_id_idx" ON "conversation_archives"("agent_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "conversation_archives_started_at_idx" ON "conversation_archives"("started_at");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "conversation_archives" ADD CONSTRAINT "conversation_archives_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -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;
|
||||||
@@ -298,6 +298,7 @@ model Workspace {
|
|||||||
agents Agent[]
|
agents Agent[]
|
||||||
agentSessions AgentSession[]
|
agentSessions AgentSession[]
|
||||||
agentTasks AgentTask[]
|
agentTasks AgentTask[]
|
||||||
|
findings Finding[]
|
||||||
userLayouts UserLayout[]
|
userLayouts UserLayout[]
|
||||||
knowledgeEntries KnowledgeEntry[]
|
knowledgeEntries KnowledgeEntry[]
|
||||||
knowledgeTags KnowledgeTag[]
|
knowledgeTags KnowledgeTag[]
|
||||||
@@ -312,6 +313,7 @@ model Workspace {
|
|||||||
llmUsageLogs LlmUsageLog[]
|
llmUsageLogs LlmUsageLog[]
|
||||||
userCredentials UserCredential[]
|
userCredentials UserCredential[]
|
||||||
terminalSessions TerminalSession[]
|
terminalSessions TerminalSession[]
|
||||||
|
conversationArchives ConversationArchive[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("workspaces")
|
@@map("workspaces")
|
||||||
@@ -689,6 +691,7 @@ model AgentTask {
|
|||||||
createdBy User @relation("AgentTaskCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
createdBy User @relation("AgentTaskCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
createdById String @map("created_by_id") @db.Uuid
|
createdById String @map("created_by_id") @db.Uuid
|
||||||
runnerJobs RunnerJob[]
|
runnerJobs RunnerJob[]
|
||||||
|
findings Finding[]
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id, workspaceId])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@ -698,6 +701,33 @@ model AgentTask {
|
|||||||
@@map("agent_tasks")
|
@@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 {
|
model AgentSession {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
workspaceId String @map("workspace_id") @db.Uuid
|
workspaceId String @map("workspace_id") @db.Uuid
|
||||||
@@ -1546,3 +1576,33 @@ model TerminalSession {
|
|||||||
@@index([workspaceId, status])
|
@@index([workspaceId, status])
|
||||||
@@map("terminal_sessions")
|
@@map("terminal_sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONVERSATION ARCHIVE MODULE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model ConversationArchive {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workspaceId String @map("workspace_id") @db.Uuid
|
||||||
|
sessionId String @map("session_id")
|
||||||
|
agentId String @map("agent_id")
|
||||||
|
messages Json
|
||||||
|
messageCount Int @map("message_count")
|
||||||
|
summary String @db.Text
|
||||||
|
// Note: vector dimension (1536) must match EMBEDDING_DIMENSION constant in @mosaic/shared
|
||||||
|
embedding Unsupported("vector(1536)")?
|
||||||
|
startedAt DateTime @map("started_at") @db.Timestamptz
|
||||||
|
endedAt DateTime? @map("ended_at") @db.Timestamptz
|
||||||
|
metadata Json @default("{}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([workspaceId, sessionId])
|
||||||
|
@@index([workspaceId])
|
||||||
|
@@index([agentId])
|
||||||
|
@@index([startedAt])
|
||||||
|
@@map("conversation_archives")
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ describe("AdminService", () => {
|
|||||||
workspaceMember: {
|
workspaceMember: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
},
|
},
|
||||||
$transaction: vi.fn(),
|
session: {
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(async (ops) => {
|
||||||
|
if (typeof ops === "function") {
|
||||||
|
return ops(mockPrismaService);
|
||||||
|
}
|
||||||
|
return Promise.all(ops);
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
|
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
@@ -82,10 +90,6 @@ describe("AdminService", () => {
|
|||||||
service = module.get<AdminService>(AdminService);
|
service = module.get<AdminService>(AdminService);
|
||||||
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
mockPrismaService.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => {
|
|
||||||
return fn(mockPrismaService);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be defined", () => {
|
it("should be defined", () => {
|
||||||
@@ -325,12 +329,13 @@ describe("AdminService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("deactivateUser", () => {
|
describe("deactivateUser", () => {
|
||||||
it("should set deactivatedAt on the user", async () => {
|
it("should set deactivatedAt and invalidate sessions", async () => {
|
||||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
mockPrismaService.user.update.mockResolvedValue({
|
mockPrismaService.user.update.mockResolvedValue({
|
||||||
...mockUser,
|
...mockUser,
|
||||||
deactivatedAt: new Date(),
|
deactivatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
mockPrismaService.session.deleteMany.mockResolvedValue({ count: 3 });
|
||||||
|
|
||||||
const result = await service.deactivateUser(mockUserId);
|
const result = await service.deactivateUser(mockUserId);
|
||||||
|
|
||||||
@@ -341,6 +346,7 @@ describe("AdminService", () => {
|
|||||||
data: { deactivatedAt: expect.any(Date) },
|
data: { deactivatedAt: expect.any(Date) },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
expect(mockPrismaService.session.deleteMany).toHaveBeenCalledWith({ where: { userId: mockUserId } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException if user does not exist", async () => {
|
it("should throw NotFoundException if user does not exist", async () => {
|
||||||
|
|||||||
@@ -192,19 +192,22 @@ export class AdminService {
|
|||||||
throw new BadRequestException(`User ${id} is already deactivated`);
|
throw new BadRequestException(`User ${id} is already deactivated`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.prisma.user.update({
|
const [user] = await this.prisma.$transaction([
|
||||||
where: { id },
|
this.prisma.user.update({
|
||||||
data: { deactivatedAt: new Date() },
|
where: { id },
|
||||||
include: {
|
data: { deactivatedAt: new Date() },
|
||||||
workspaceMemberships: {
|
include: {
|
||||||
include: {
|
workspaceMemberships: {
|
||||||
workspace: { select: { id: true, name: true } },
|
include: {
|
||||||
|
workspace: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
});
|
this.prisma.session.deleteMany({ where: { userId: id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
this.logger.log(`User deactivated: ${id}`);
|
this.logger.log(`User deactivated and sessions invalidated: ${id}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { LlmUsageModule } from "./llm-usage/llm-usage.module";
|
|||||||
import { BrainModule } from "./brain/brain.module";
|
import { BrainModule } from "./brain/brain.module";
|
||||||
import { CronModule } from "./cron/cron.module";
|
import { CronModule } from "./cron/cron.module";
|
||||||
import { AgentTasksModule } from "./agent-tasks/agent-tasks.module";
|
import { AgentTasksModule } from "./agent-tasks/agent-tasks.module";
|
||||||
|
import { FindingsModule } from "./findings/findings.module";
|
||||||
import { ValkeyModule } from "./valkey/valkey.module";
|
import { ValkeyModule } from "./valkey/valkey.module";
|
||||||
import { BullMqModule } from "./bullmq/bullmq.module";
|
import { BullMqModule } from "./bullmq/bullmq.module";
|
||||||
import { StitcherModule } from "./stitcher/stitcher.module";
|
import { StitcherModule } from "./stitcher/stitcher.module";
|
||||||
@@ -46,6 +47,7 @@ import { WorkspacesModule } from "./workspaces/workspaces.module";
|
|||||||
import { AdminModule } from "./admin/admin.module";
|
import { AdminModule } from "./admin/admin.module";
|
||||||
import { TeamsModule } from "./teams/teams.module";
|
import { TeamsModule } from "./teams/teams.module";
|
||||||
import { ImportModule } from "./import/import.module";
|
import { ImportModule } from "./import/import.module";
|
||||||
|
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
|
||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -100,6 +102,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
BrainModule,
|
BrainModule,
|
||||||
CronModule,
|
CronModule,
|
||||||
AgentTasksModule,
|
AgentTasksModule,
|
||||||
|
FindingsModule,
|
||||||
RunnerJobsModule,
|
RunnerJobsModule,
|
||||||
JobEventsModule,
|
JobEventsModule,
|
||||||
JobStepsModule,
|
JobStepsModule,
|
||||||
@@ -115,6 +118,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
AdminModule,
|
AdminModule,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
|
ConversationArchiveModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Controller, Post, Get, Body, Param, Query, UseGuards } from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
|
import { Workspace, RequirePermission, Permission } from "../common/decorators";
|
||||||
|
import { ConversationArchiveService } from "./conversation-archive.service";
|
||||||
|
import { IngestConversationDto, SearchConversationDto, ListConversationsDto } from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for conversation archive endpoints.
|
||||||
|
* All endpoints require workspace membership.
|
||||||
|
*/
|
||||||
|
@Controller("conversations")
|
||||||
|
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||||
|
export class ConversationArchiveController {
|
||||||
|
constructor(private readonly service: ConversationArchiveService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/conversations/ingest
|
||||||
|
* Ingest a conversation session log and auto-embed for semantic search.
|
||||||
|
* Requires: MEMBER or higher
|
||||||
|
*/
|
||||||
|
@Post("ingest")
|
||||||
|
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||||
|
async ingest(
|
||||||
|
@Workspace() workspaceId: string,
|
||||||
|
@Body() dto: IngestConversationDto
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
return this.service.ingest(workspaceId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/conversations/search
|
||||||
|
* Vector similarity search across archived conversations.
|
||||||
|
* Requires: Any workspace member
|
||||||
|
*/
|
||||||
|
@Post("search")
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
|
async search(
|
||||||
|
@Workspace() workspaceId: string,
|
||||||
|
@Body() dto: SearchConversationDto
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.service.search(workspaceId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/conversations
|
||||||
|
* List conversation archives with filtering and pagination.
|
||||||
|
* Requires: Any workspace member
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
|
async findAll(
|
||||||
|
@Workspace() workspaceId: string,
|
||||||
|
@Query() query: ListConversationsDto
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.service.findAll(workspaceId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/conversations/:id
|
||||||
|
* Get a single conversation archive by ID (includes full messages).
|
||||||
|
* Requires: Any workspace member
|
||||||
|
*/
|
||||||
|
@Get(":id")
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
|
async findOne(@Workspace() workspaceId: string, @Param("id") id: string): Promise<unknown> {
|
||||||
|
return this.service.findOne(workspaceId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { ConversationArchiveService } from "./conversation-archive.service";
|
||||||
|
import { ConversationArchiveController } from "./conversation-archive.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule, KnowledgeModule],
|
||||||
|
controllers: [ConversationArchiveController],
|
||||||
|
providers: [ConversationArchiveService],
|
||||||
|
exports: [ConversationArchiveService],
|
||||||
|
})
|
||||||
|
export class ConversationArchiveModule {}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { ConflictException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { ConversationArchiveService } from "./conversation-archive.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { EmbeddingService } from "../knowledge/services/embedding.service";
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
conversationArchive: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
$queryRaw: vi.fn(),
|
||||||
|
$executeRaw: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEmbedding = {
|
||||||
|
isConfigured: vi.fn(),
|
||||||
|
generateEmbedding: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ConversationArchiveService", () => {
|
||||||
|
let service: ConversationArchiveService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ConversationArchiveService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: EmbeddingService, useValue: mockEmbedding },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<ConversationArchiveService>(ConversationArchiveService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ingest", () => {
|
||||||
|
const workspaceId = "ws-1";
|
||||||
|
const dto = {
|
||||||
|
sessionId: "sess-abc",
|
||||||
|
agentId: "agent-xyz",
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
{ role: "assistant", content: "Hi there!" },
|
||||||
|
],
|
||||||
|
summary: "A greeting conversation",
|
||||||
|
startedAt: "2026-02-28T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("creates a conversation archive and returns id", async () => {
|
||||||
|
mockPrisma.conversationArchive.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrisma.conversationArchive.create.mockResolvedValue({ id: "conv-1" });
|
||||||
|
mockEmbedding.isConfigured.mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await service.ingest(workspaceId, dto);
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "conv-1" });
|
||||||
|
expect(mockPrisma.conversationArchive.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
workspaceId,
|
||||||
|
sessionId: dto.sessionId,
|
||||||
|
agentId: dto.agentId,
|
||||||
|
messageCount: 2,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ConflictException when session already exists", async () => {
|
||||||
|
mockPrisma.conversationArchive.findUnique.mockResolvedValue({ id: "existing" });
|
||||||
|
|
||||||
|
await expect(service.ingest(workspaceId, dto)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
const workspaceId = "ws-1";
|
||||||
|
|
||||||
|
it("returns paginated list", async () => {
|
||||||
|
mockPrisma.conversationArchive.count.mockResolvedValue(5);
|
||||||
|
mockPrisma.conversationArchive.findMany.mockResolvedValue([
|
||||||
|
{ id: "conv-1", sessionId: "sess-1" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.findAll(workspaceId, { page: 1, limit: 10 });
|
||||||
|
|
||||||
|
expect(result.pagination.total).toBe(5);
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default pagination when not provided", async () => {
|
||||||
|
mockPrisma.conversationArchive.count.mockResolvedValue(0);
|
||||||
|
mockPrisma.conversationArchive.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.findAll(workspaceId, {});
|
||||||
|
|
||||||
|
expect(result.pagination.page).toBe(1);
|
||||||
|
expect(result.pagination.limit).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOne", () => {
|
||||||
|
const workspaceId = "ws-1";
|
||||||
|
|
||||||
|
it("returns record when found", async () => {
|
||||||
|
const record = { id: "conv-1", workspaceId, sessionId: "sess-1" };
|
||||||
|
mockPrisma.conversationArchive.findFirst.mockResolvedValue(record);
|
||||||
|
|
||||||
|
const result = await service.findOne(workspaceId, "conv-1");
|
||||||
|
|
||||||
|
expect(result).toEqual(record);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NotFoundException when record does not exist", async () => {
|
||||||
|
mockPrisma.conversationArchive.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.findOne(workspaceId, "missing")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("search", () => {
|
||||||
|
it("throws ConflictException when embedding is not configured", async () => {
|
||||||
|
mockEmbedding.isConfigured.mockReturnValue(false);
|
||||||
|
|
||||||
|
await expect(service.search("ws-1", { query: "test query" })).rejects.toThrow(
|
||||||
|
ConflictException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs vector search when configured", async () => {
|
||||||
|
mockEmbedding.isConfigured.mockReturnValue(true);
|
||||||
|
mockEmbedding.generateEmbedding.mockResolvedValue(new Array(1536).fill(0.1));
|
||||||
|
mockPrisma.$queryRaw
|
||||||
|
.mockResolvedValueOnce([{ id: "conv-1", similarity: 0.9 }])
|
||||||
|
.mockResolvedValueOnce([{ count: BigInt(1) }]);
|
||||||
|
|
||||||
|
const result = await service.search("ws-1", { query: "greetings" });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.pagination.total).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException, ConflictException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { EMBEDDING_DIMENSION } from "@mosaic/shared";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { EmbeddingService } from "../knowledge/services/embedding.service";
|
||||||
|
import type { IngestConversationDto, SearchConversationDto, ListConversationsDto } from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of a raw conversation archive row from $queryRaw vector search
|
||||||
|
*/
|
||||||
|
interface RawConversationResult {
|
||||||
|
id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
session_id: string;
|
||||||
|
agent_id: string;
|
||||||
|
messages: unknown;
|
||||||
|
message_count: number;
|
||||||
|
summary: string;
|
||||||
|
started_at: Date;
|
||||||
|
ended_at: Date | null;
|
||||||
|
metadata: unknown;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
similarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated response wrapper
|
||||||
|
*/
|
||||||
|
export interface PaginatedConversations<T> {
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConversationArchiveService {
|
||||||
|
private readonly logger = new Logger(ConversationArchiveService.name);
|
||||||
|
private readonly defaultSimilarityThreshold = 0.5;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly embedding: EmbeddingService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingest a conversation session log.
|
||||||
|
* Generates a vector embedding from the summary + message content and stores it alongside the record.
|
||||||
|
*/
|
||||||
|
async ingest(workspaceId: string, dto: IngestConversationDto): Promise<{ id: string }> {
|
||||||
|
// Verify no duplicate session in this workspace
|
||||||
|
const existing = await this.prisma.conversationArchive.findUnique({
|
||||||
|
where: { workspaceId_sessionId: { workspaceId, sessionId: dto.sessionId } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Conversation session '${dto.sessionId}' already exists in this workspace`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageCount = dto.messages.length;
|
||||||
|
|
||||||
|
// Create record first to get ID for embedding
|
||||||
|
const record = await this.prisma.conversationArchive.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
sessionId: dto.sessionId,
|
||||||
|
agentId: dto.agentId,
|
||||||
|
messages: dto.messages as unknown as Prisma.InputJsonValue,
|
||||||
|
messageCount,
|
||||||
|
summary: dto.summary,
|
||||||
|
startedAt: new Date(dto.startedAt),
|
||||||
|
endedAt: dto.endedAt ? new Date(dto.endedAt) : null,
|
||||||
|
metadata: (dto.metadata ?? {}) as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate and store embedding asynchronously (non-blocking for ingest)
|
||||||
|
if (this.embedding.isConfigured()) {
|
||||||
|
const textForEmbedding = this.buildEmbeddingText(dto.summary, dto.messages);
|
||||||
|
this.storeEmbedding(record.id, textForEmbedding).catch((err: unknown) => {
|
||||||
|
this.logger.error(`Failed to store embedding for conversation ${record.id}`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Ingested conversation ${record.id} (session: ${dto.sessionId})`);
|
||||||
|
return { id: record.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semantic vector search across conversation archives in a workspace.
|
||||||
|
*/
|
||||||
|
async search(
|
||||||
|
workspaceId: string,
|
||||||
|
dto: SearchConversationDto
|
||||||
|
): Promise<PaginatedConversations<RawConversationResult>> {
|
||||||
|
if (!this.embedding.isConfigured()) {
|
||||||
|
throw new ConflictException("Semantic search requires OpenAI API key to be configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = dto.limit ?? 20;
|
||||||
|
const threshold = dto.similarityThreshold ?? this.defaultSimilarityThreshold;
|
||||||
|
const distanceThreshold = 1 - threshold;
|
||||||
|
|
||||||
|
const queryEmbedding = await this.embedding.generateEmbedding(dto.query);
|
||||||
|
const embeddingStr = `[${queryEmbedding.join(",")}]`;
|
||||||
|
|
||||||
|
const agentFilter = dto.agentId ? Prisma.sql`AND ca.agent_id = ${dto.agentId}` : Prisma.sql``;
|
||||||
|
|
||||||
|
const rows = await this.prisma.$queryRaw<RawConversationResult[]>`
|
||||||
|
SELECT
|
||||||
|
ca.id,
|
||||||
|
ca.workspace_id,
|
||||||
|
ca.session_id,
|
||||||
|
ca.agent_id,
|
||||||
|
ca.messages,
|
||||||
|
ca.message_count,
|
||||||
|
ca.summary,
|
||||||
|
ca.started_at,
|
||||||
|
ca.ended_at,
|
||||||
|
ca.metadata,
|
||||||
|
ca.created_at,
|
||||||
|
ca.updated_at,
|
||||||
|
(1 - (ca.embedding <=> ${embeddingStr}::vector(${EMBEDDING_DIMENSION}))) AS similarity
|
||||||
|
FROM conversation_archives ca
|
||||||
|
WHERE ca.workspace_id = ${workspaceId}::uuid
|
||||||
|
AND ca.embedding IS NOT NULL
|
||||||
|
AND (ca.embedding <=> ${embeddingStr}::vector(${EMBEDDING_DIMENSION})) <= ${distanceThreshold}
|
||||||
|
${agentFilter}
|
||||||
|
ORDER BY ca.embedding <=> ${embeddingStr}::vector(${EMBEDDING_DIMENSION})
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>`
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM conversation_archives ca
|
||||||
|
WHERE ca.workspace_id = ${workspaceId}::uuid
|
||||||
|
AND ca.embedding IS NOT NULL
|
||||||
|
AND (ca.embedding <=> ${embeddingStr}::vector(${EMBEDDING_DIMENSION})) <= ${distanceThreshold}
|
||||||
|
${agentFilter}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const total = Number(countResult[0].count);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows,
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List conversation archives with filtering and pagination.
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
workspaceId: string,
|
||||||
|
query: ListConversationsDto
|
||||||
|
): Promise<PaginatedConversations<object>> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: Prisma.ConversationArchiveWhereInput = {
|
||||||
|
workspaceId,
|
||||||
|
...(query.agentId ? { agentId: query.agentId } : {}),
|
||||||
|
...(query.startedAfter || query.startedBefore
|
||||||
|
? {
|
||||||
|
startedAt: {
|
||||||
|
...(query.startedAfter ? { gte: new Date(query.startedAfter) } : {}),
|
||||||
|
...(query.startedBefore ? { lte: new Date(query.startedBefore) } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [total, records] = await Promise.all([
|
||||||
|
this.prisma.conversationArchive.count({ where }),
|
||||||
|
this.prisma.conversationArchive.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
workspaceId: true,
|
||||||
|
sessionId: true,
|
||||||
|
agentId: true,
|
||||||
|
messageCount: true,
|
||||||
|
summary: true,
|
||||||
|
startedAt: true,
|
||||||
|
endedAt: true,
|
||||||
|
metadata: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: records,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single conversation archive by ID.
|
||||||
|
*/
|
||||||
|
async findOne(workspaceId: string, id: string): Promise<object> {
|
||||||
|
const record = await this.prisma.conversationArchive.findFirst({
|
||||||
|
where: { id, workspaceId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
workspaceId: true,
|
||||||
|
sessionId: true,
|
||||||
|
agentId: true,
|
||||||
|
messages: true,
|
||||||
|
messageCount: true,
|
||||||
|
summary: true,
|
||||||
|
startedAt: true,
|
||||||
|
endedAt: true,
|
||||||
|
metadata: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new NotFoundException(`Conversation archive '${id}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build text content for embedding from summary and messages.
|
||||||
|
*/
|
||||||
|
private buildEmbeddingText(
|
||||||
|
summary: string,
|
||||||
|
messages: { role: string; content: string }[]
|
||||||
|
): string {
|
||||||
|
const messageText = messages.map((m) => `${m.role}: ${m.content}`).join("\n");
|
||||||
|
return `${summary}\n\n${messageText}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding and store it on the conversation_archives row.
|
||||||
|
*/
|
||||||
|
private async storeEmbedding(id: string, text: string): Promise<void> {
|
||||||
|
const vector = await this.embedding.generateEmbedding(text);
|
||||||
|
const embeddingStr = `[${vector.join(",")}]`;
|
||||||
|
|
||||||
|
await this.prisma.$executeRaw`
|
||||||
|
UPDATE conversation_archives
|
||||||
|
SET embedding = ${embeddingStr}::vector(${EMBEDDING_DIMENSION}),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${id}::uuid
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.logger.log(`Stored embedding for conversation ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
apps/api/src/conversation-archive/dto/index.ts
Normal file
3
apps/api/src/conversation-archive/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { IngestConversationDto, ConversationMessageDto } from "./ingest-conversation.dto";
|
||||||
|
export { SearchConversationDto } from "./search-conversation.dto";
|
||||||
|
export { ListConversationsDto } from "./list-conversations.dto";
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsArray,
|
||||||
|
IsOptional,
|
||||||
|
IsDateString,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
IsObject,
|
||||||
|
ValidateNested,
|
||||||
|
ArrayMinSize,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single message in a conversation session
|
||||||
|
*/
|
||||||
|
export class ConversationMessageDto {
|
||||||
|
@IsString()
|
||||||
|
role!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for ingesting a conversation session log
|
||||||
|
*/
|
||||||
|
export class IngestConversationDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(500)
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(500)
|
||||||
|
agentId!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ConversationMessageDto)
|
||||||
|
messages!: ConversationMessageDto[];
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
summary!: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
startedAt!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endedAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { IsString, IsOptional, MaxLength, IsInt, Min, Max, IsDateString } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for listing/filtering conversation archives
|
||||||
|
*/
|
||||||
|
export class ListConversationsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
agentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startedAfter?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startedBefore?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
IsNumber,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for semantic search across conversation archives
|
||||||
|
*/
|
||||||
|
export class SearchConversationDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(1000)
|
||||||
|
query!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
agentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
similarityThreshold?: number;
|
||||||
|
}
|
||||||
33
apps/api/src/findings/dto/create-finding.dto.ts
Normal file
33
apps/api/src/findings/dto/create-finding.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
3
apps/api/src/findings/dto/index.ts
Normal file
3
apps/api/src/findings/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { CreateFindingDto } from "./create-finding.dto";
|
||||||
|
export { QueryFindingsDto } from "./query-findings.dto";
|
||||||
|
export { SearchFindingsDto } from "./search-findings.dto";
|
||||||
32
apps/api/src/findings/dto/query-findings.dto.ts
Normal file
32
apps/api/src/findings/dto/query-findings.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
52
apps/api/src/findings/dto/search-findings.dto.ts
Normal file
52
apps/api/src/findings/dto/search-findings.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
195
apps/api/src/findings/findings.controller.spec.ts
Normal file
195
apps/api/src/findings/findings.controller.spec.ts
Normal file
@@ -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>(FindingsController);
|
||||||
|
service = module.get<FindingsService>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
81
apps/api/src/findings/findings.controller.ts
Normal file
81
apps/api/src/findings/findings.controller.ts
Normal file
@@ -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<PaginatedFindingsResponse> {
|
||||||
|
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<FindingsSearchResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/findings/findings.module.ts
Normal file
14
apps/api/src/findings/findings.module.ts
Normal file
@@ -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 {}
|
||||||
300
apps/api/src/findings/findings.service.spec.ts
Normal file
300
apps/api/src/findings/findings.service.spec.ts
Normal file
@@ -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>(FindingsService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
embeddingService = module.get<EmbeddingService>(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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
337
apps/api/src/findings/findings.service.ts
Normal file
337
apps/api/src/findings/findings.service.ts
Normal file
@@ -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<FindingRecord> {
|
||||||
|
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<PaginatedFindingsResponse> {
|
||||||
|
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<FindingRecord> {
|
||||||
|
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<FindingsSearchResponse> {
|
||||||
|
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<RawFindingSearchResult[]>`
|
||||||
|
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<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
|
||||||
import {
|
import {
|
||||||
deactivateUser,
|
deactivateUser,
|
||||||
fetchAdminUsers,
|
fetchAdminUsers,
|
||||||
@@ -105,6 +106,8 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
const [editError, setEditError] = useState<string | null>(null);
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
|
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
if (showLoadingState) {
|
if (showLoadingState) {
|
||||||
@@ -129,6 +132,20 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
void loadUsers(true);
|
void loadUsers(true);
|
||||||
}, [loadUsers]);
|
}, [loadUsers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserWorkspaces()
|
||||||
|
.then((workspaces) => {
|
||||||
|
const adminRoles: WorkspaceMemberRole[] = [
|
||||||
|
WorkspaceMemberRole.OWNER,
|
||||||
|
WorkspaceMemberRole.ADMIN,
|
||||||
|
];
|
||||||
|
setIsAdmin(workspaces.some((ws) => adminRoles.includes(ws.role)));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setIsAdmin(true); // fail open
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
function resetInviteForm(): void {
|
function resetInviteForm(): void {
|
||||||
setInviteForm(INITIAL_INVITE_FORM);
|
setInviteForm(INITIAL_INVITE_FORM);
|
||||||
setInviteError(null);
|
setInviteError(null);
|
||||||
@@ -212,6 +229,17 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAdmin === false) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-2xl">
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||||
|
<p className="text-lg font-semibold text-red-700">Access Denied</p>
|
||||||
|
<p className="mt-2 text-sm text-red-600">You need Admin or Owner role to manage users.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
|||||||
60
apps/web/src/lib/api/admin.test.ts
Normal file
60
apps/web/src/lib/api/admin.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import * as client from "./client";
|
||||||
|
import { fetchAdminUsers, inviteUser, updateUser, deactivateUser } from "./admin";
|
||||||
|
|
||||||
|
vi.mock("./client");
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchAdminUsers", (): void => {
|
||||||
|
it("calls admin/users endpoint without params when none provided", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiGet).mockResolvedValueOnce({ data: [], meta: {} } as never);
|
||||||
|
await fetchAdminUsers();
|
||||||
|
expect(client.apiGet).toHaveBeenCalledWith("/api/admin/users");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends page and limit params when provided", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiGet).mockResolvedValueOnce({ data: [], meta: {} } as never);
|
||||||
|
await fetchAdminUsers(2, 50);
|
||||||
|
expect(client.apiGet).toHaveBeenCalledWith("/api/admin/users?page=2&limit=50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on API error", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiGet).mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
await expect(fetchAdminUsers()).rejects.toThrow("Network error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inviteUser", (): void => {
|
||||||
|
it("posts to invite endpoint", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "inv-1" } as never);
|
||||||
|
await inviteUser({
|
||||||
|
email: "a@b.com",
|
||||||
|
name: "Alice",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
role: "MEMBER" as never,
|
||||||
|
});
|
||||||
|
expect(client.apiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/admin/users/invite",
|
||||||
|
expect.objectContaining({ email: "a@b.com" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateUser", (): void => {
|
||||||
|
it("patches correct endpoint with dto", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiPatch).mockResolvedValueOnce({ id: "u1", name: "Bob" } as never);
|
||||||
|
await updateUser("u1", { name: "Bob" });
|
||||||
|
expect(client.apiPatch).toHaveBeenCalledWith("/api/admin/users/u1", { name: "Bob" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deactivateUser", (): void => {
|
||||||
|
it("deletes correct endpoint", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiDelete).mockResolvedValueOnce({} as never);
|
||||||
|
await deactivateUser("u1");
|
||||||
|
expect(client.apiDelete).toHaveBeenCalledWith("/api/admin/users/u1");
|
||||||
|
});
|
||||||
|
});
|
||||||
53
apps/web/src/lib/api/teams.test.ts
Normal file
53
apps/web/src/lib/api/teams.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import * as client from "./client";
|
||||||
|
import { fetchTeams, createTeam, fetchTeamMembers } from "./teams";
|
||||||
|
|
||||||
|
vi.mock("./client");
|
||||||
|
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: vi.fn().mockReturnValue("ws-1"),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
length: 0,
|
||||||
|
key: vi.fn(),
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorageMock.getItem.mockReturnValue("ws-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchTeams", (): void => {
|
||||||
|
it("calls teams endpoint for active workspace", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
|
||||||
|
await fetchTeams();
|
||||||
|
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/teams", "ws-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if no workspace id in localStorage", async (): Promise<void> => {
|
||||||
|
localStorageMock.getItem.mockReturnValue(null);
|
||||||
|
await expect(fetchTeams()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTeam", (): void => {
|
||||||
|
it("posts to teams endpoint", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "t1", name: "Dev" } as never);
|
||||||
|
await createTeam({ name: "Dev" });
|
||||||
|
expect(client.apiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/workspaces/ws-1/teams",
|
||||||
|
expect.objectContaining({ name: "Dev" }),
|
||||||
|
"ws-1"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchTeamMembers", (): void => {
|
||||||
|
it("calls members endpoint for team", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
|
||||||
|
await fetchTeamMembers("t-1");
|
||||||
|
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/teams/t-1/members", "ws-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
65
apps/web/src/lib/api/workspaces.test.ts
Normal file
65
apps/web/src/lib/api/workspaces.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import * as client from "./client";
|
||||||
|
import {
|
||||||
|
fetchUserWorkspaces,
|
||||||
|
fetchWorkspaceMembers,
|
||||||
|
addWorkspaceMember,
|
||||||
|
updateWorkspaceMemberRole,
|
||||||
|
removeWorkspaceMember,
|
||||||
|
} from "./workspaces";
|
||||||
|
|
||||||
|
vi.mock("./client");
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchUserWorkspaces", (): void => {
|
||||||
|
it("calls correct endpoint", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
|
||||||
|
await fetchUserWorkspaces();
|
||||||
|
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchWorkspaceMembers", (): void => {
|
||||||
|
it("calls correct endpoint with workspace id", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
|
||||||
|
await fetchWorkspaceMembers("ws-1");
|
||||||
|
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/members");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on error", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiGet).mockRejectedValueOnce(new Error("Forbidden"));
|
||||||
|
await expect(fetchWorkspaceMembers("ws-1")).rejects.toThrow("Forbidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addWorkspaceMember", (): void => {
|
||||||
|
it("posts to correct endpoint", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiPost).mockResolvedValueOnce({} as never);
|
||||||
|
await addWorkspaceMember("ws-1", { userId: "u1", role: "MEMBER" as never });
|
||||||
|
expect(client.apiPost).toHaveBeenCalledWith("/api/workspaces/ws-1/members", {
|
||||||
|
userId: "u1",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateWorkspaceMemberRole", (): void => {
|
||||||
|
it("patches correct endpoint", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiPatch).mockResolvedValueOnce({} as never);
|
||||||
|
await updateWorkspaceMemberRole("ws-1", "u1", { role: "ADMIN" as never });
|
||||||
|
expect(client.apiPatch).toHaveBeenCalledWith("/api/workspaces/ws-1/members/u1", {
|
||||||
|
role: "ADMIN",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeWorkspaceMember", (): void => {
|
||||||
|
it("calls delete on correct endpoint", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never);
|
||||||
|
await removeWorkspaceMember("ws-1", "u1");
|
||||||
|
expect(client.apiDelete).toHaveBeenCalledWith("/api/workspaces/ws-1/members/u1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,11 +33,11 @@
|
|||||||
| MS21-TEST-004 | not-started | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | |
|
| MS21-TEST-004 | not-started | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | |
|
||||||
| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | |
|
| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | |
|
||||||
| MS21-RBAC-002 | not-started | phase-5 | Settings page access restriction | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
|
| MS21-RBAC-002 | not-started | phase-5 | Settings page access restriction | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
|
||||||
| MS21-RBAC-003 | not-started | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
|
| MS21-RBAC-003 | done | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
|
||||||
| MS21-RBAC-004 | not-started | phase-5 | User profile role display | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 5K | — | |
|
| MS21-RBAC-004 | done | phase-5 | User profile role display | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 5K | — | |
|
||||||
| MS21-VER-001 | not-started | phase-6 | Full quality gate pass | #571 | stack | — | MS21-TEST-004,MS21-RBAC-004,MS21-MIG-003 | MS21-VER-002 | — | — | — | 5K | — | |
|
| MS21-VER-001 | done | phase-6 | Full quality gate pass | #571 | stack | — | MS21-TEST-004,MS21-RBAC-004,MS21-MIG-003 | MS21-VER-002 | — | — | — | 5K | — | |
|
||||||
| MS21-VER-002 | not-started | phase-6 | Deploy and smoke test | #571 | stack | — | MS21-VER-001 | MS21-VER-003 | — | — | — | 5K | — | |
|
| MS21-VER-002 | done | phase-6 | Deploy and smoke test | #571 | stack | — | MS21-VER-001 | MS21-VER-003 | — | — | — | 5K | — | |
|
||||||
| MS21-VER-003 | not-started | phase-6 | Tag v0.0.21 | #571 | stack | — | MS21-VER-002 | — | — | — | — | 2K | — | |
|
| MS21-VER-003 | done | phase-6 | Tag v0.0.21 | #571 | stack | — | MS21-VER-002 | — | — | — | — | 2K | — | |
|
||||||
|
|
||||||
## Budget Summary
|
## Budget Summary
|
||||||
|
|
||||||
|
|||||||
48
docs/scratchpads/ms22-conversation-archive.md
Normal file
48
docs/scratchpads/ms22-conversation-archive.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# MS22 — Conversation Archive Module
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement ConversationArchive module: ingest OpenClaw session logs, store with vector embeddings for semantic search.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. ConversationArchive Prisma model
|
||||||
|
2. NestJS module at apps/api/src/conversation-archive/
|
||||||
|
3. Endpoints: ingest, search, list, get-by-id
|
||||||
|
4. Register in app.module.ts
|
||||||
|
5. Migrate, lint, build, commit
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
- Add model to schema.prisma (end of file)
|
||||||
|
- Add relation to Workspace model
|
||||||
|
- Create module structure: dto/, service, controller, spec, module
|
||||||
|
- Use EmbeddingService from knowledge module (import KnowledgeModule or just PrismaModule + embed inline)
|
||||||
|
- Follow pattern: AuthGuard + WorkspaceGuard + PermissionGuard
|
||||||
|
- Endpoint prefix: conversations (maps to /api/conversations)
|
||||||
|
- Vector search: $queryRaw with <=> operator (cosine distance)
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- ASSUMPTION: Embedding is stored inline on ConversationArchive (not a separate table) — simpler and sufficient for this use case, matches MemoryEmbedding pattern
|
||||||
|
- ASSUMPTION: Import KnowledgeModule to reuse EmbeddingService (it exports it)
|
||||||
|
- ASSUMPTION: messageCount computed server-side from messages array length on ingest
|
||||||
|
- ASSUMPTION: Permission level WORKSPACE_MEMBER for ingest/search, WORKSPACE_ANY for list/get
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [ ] Schema model
|
||||||
|
- [ ] Migration
|
||||||
|
- [ ] DTOs
|
||||||
|
- [ ] Service
|
||||||
|
- [ ] Controller
|
||||||
|
- [ ] Spec
|
||||||
|
- [ ] Module
|
||||||
|
- [ ] app.module.ts registration
|
||||||
|
- [ ] Lint + build
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- EmbeddingService exports from knowledge.module — need to import KnowledgeModule
|
||||||
|
- Migration requires live DB (may need --skip-generate flag if no DB access)
|
||||||
@@ -74,7 +74,8 @@
|
|||||||
"tough-cookie": ">=4.1.3",
|
"tough-cookie": ">=4.1.3",
|
||||||
"undici": ">=6.23.0",
|
"undici": ">=6.23.0",
|
||||||
"rollup": ">=4.59.0",
|
"rollup": ">=4.59.0",
|
||||||
"serialize-javascript": ">=7.0.3"
|
"serialize-javascript": ">=7.0.3",
|
||||||
|
"multer": ">=2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -17,6 +17,7 @@ overrides:
|
|||||||
undici: '>=6.23.0'
|
undici: '>=6.23.0'
|
||||||
rollup: '>=4.59.0'
|
rollup: '>=4.59.0'
|
||||||
serialize-javascript: '>=7.0.3'
|
serialize-javascript: '>=7.0.3'
|
||||||
|
multer: '>=2.1.0'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@@ -1603,6 +1604,7 @@ packages:
|
|||||||
|
|
||||||
'@mosaicstack/telemetry-client@0.1.1':
|
'@mosaicstack/telemetry-client@0.1.1':
|
||||||
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@mrleebo/prisma-ast@0.13.1':
|
'@mrleebo/prisma-ast@0.13.1':
|
||||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||||
@@ -5805,10 +5807,6 @@ packages:
|
|||||||
mkdirp-classic@0.5.3:
|
mkdirp-classic@0.5.3:
|
||||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||||
|
|
||||||
mkdirp@0.5.6:
|
|
||||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
mkdirp@3.0.1:
|
mkdirp@3.0.1:
|
||||||
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
|
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5837,8 +5835,8 @@ packages:
|
|||||||
msgpackr@1.11.5:
|
msgpackr@1.11.5:
|
||||||
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
|
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
|
||||||
|
|
||||||
multer@2.0.2:
|
multer@2.1.0:
|
||||||
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
|
resolution: {integrity: sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==}
|
||||||
engines: {node: '>= 10.16.0'}
|
engines: {node: '>= 10.16.0'}
|
||||||
|
|
||||||
mute-stream@2.0.0:
|
mute-stream@2.0.0:
|
||||||
@@ -8842,7 +8840,7 @@ snapshots:
|
|||||||
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
cors: 2.8.5
|
cors: 2.8.5
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
multer: 2.0.2
|
multer: 2.1.0
|
||||||
path-to-regexp: 8.3.0
|
path-to-regexp: 8.3.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -13391,10 +13389,6 @@ snapshots:
|
|||||||
|
|
||||||
mkdirp-classic@0.5.3: {}
|
mkdirp-classic@0.5.3: {}
|
||||||
|
|
||||||
mkdirp@0.5.6:
|
|
||||||
dependencies:
|
|
||||||
minimist: 1.2.8
|
|
||||||
|
|
||||||
mkdirp@3.0.1: {}
|
mkdirp@3.0.1: {}
|
||||||
|
|
||||||
mlly@1.8.0:
|
mlly@1.8.0:
|
||||||
@@ -13436,15 +13430,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
msgpackr-extract: 3.0.3
|
msgpackr-extract: 3.0.3
|
||||||
|
|
||||||
multer@2.0.2:
|
multer@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
append-field: 1.0.0
|
append-field: 1.0.0
|
||||||
busboy: 1.6.0
|
busboy: 1.6.0
|
||||||
concat-stream: 2.0.0
|
concat-stream: 2.0.0
|
||||||
mkdirp: 0.5.6
|
|
||||||
object-assign: 4.1.1
|
|
||||||
type-is: 1.6.18
|
type-is: 1.6.18
|
||||||
xtend: 4.0.2
|
|
||||||
|
|
||||||
mute-stream@2.0.0: {}
|
mute-stream@2.0.0: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user