diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 10e32b7..ad47814 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -16,6 +16,7 @@ import { KnowledgeModule } from "./knowledge/knowledge.module"; import { UsersModule } from "./users/users.module"; import { WebSocketModule } from "./websocket/websocket.module"; import { LlmModule } from "./llm/llm.module"; +import { BrainModule } from "./brain/brain.module"; @Module({ imports: [ @@ -34,6 +35,7 @@ import { LlmModule } from "./llm/llm.module"; UsersModule, WebSocketModule, LlmModule, + BrainModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/api/src/brain/brain.controller.test.ts b/apps/api/src/brain/brain.controller.test.ts new file mode 100644 index 0000000..d374259 --- /dev/null +++ b/apps/api/src/brain/brain.controller.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { BrainController } from "./brain.controller"; +import { BrainService, BrainQueryResult, BrainContext } from "./brain.service"; +import { TaskStatus, TaskPriority, ProjectStatus, EntityType } from "@prisma/client"; + +describe("BrainController", () => { + let controller: BrainController; + let mockService: { + query: ReturnType; + getContext: ReturnType; + search: ReturnType; + }; + + const mockWorkspaceId = "123e4567-e89b-12d3-a456-426614174000"; + + const mockQueryResult: BrainQueryResult = { + tasks: [ + { + id: "task-1", + title: "Test Task", + description: null, + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + dueDate: null, + assignee: null, + project: null, + }, + ], + events: [ + { + id: "event-1", + title: "Test Event", + description: null, + startTime: new Date("2025-02-01T10:00:00Z"), + endTime: new Date("2025-02-01T11:00:00Z"), + allDay: false, + location: null, + project: null, + }, + ], + projects: [ + { + id: "project-1", + name: "Test Project", + description: null, + status: ProjectStatus.ACTIVE, + startDate: null, + endDate: null, + color: null, + _count: { tasks: 5, events: 2 }, + }, + ], + meta: { + totalTasks: 1, + totalEvents: 1, + totalProjects: 1, + filters: {}, + }, + }; + + const mockContext: BrainContext = { + timestamp: new Date(), + workspace: { id: mockWorkspaceId, name: "Test Workspace" }, + summary: { + activeTasks: 10, + overdueTasks: 2, + upcomingEvents: 5, + activeProjects: 3, + }, + tasks: [ + { + id: "task-1", + title: "Test Task", + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + dueDate: null, + isOverdue: false, + }, + ], + events: [ + { + id: "event-1", + title: "Test Event", + startTime: new Date("2025-02-01T10:00:00Z"), + endTime: new Date("2025-02-01T11:00:00Z"), + allDay: false, + location: null, + }, + ], + projects: [ + { + id: "project-1", + name: "Test Project", + status: ProjectStatus.ACTIVE, + taskCount: 5, + }, + ], + }; + + beforeEach(() => { + mockService = { + query: vi.fn().mockResolvedValue(mockQueryResult), + getContext: vi.fn().mockResolvedValue(mockContext), + search: vi.fn().mockResolvedValue(mockQueryResult), + }; + + controller = new BrainController(mockService as unknown as BrainService); + }); + + describe("query", () => { + it("should call service.query with merged workspaceId", async () => { + const queryDto = { + workspaceId: "different-id", + query: "What tasks are due?", + }; + + const result = await controller.query(queryDto, mockWorkspaceId); + + expect(mockService.query).toHaveBeenCalledWith({ + ...queryDto, + workspaceId: mockWorkspaceId, + }); + expect(result).toEqual(mockQueryResult); + }); + + it("should handle query with filters", async () => { + const queryDto = { + workspaceId: mockWorkspaceId, + entities: [EntityType.TASK, EntityType.EVENT], + tasks: { status: TaskStatus.IN_PROGRESS }, + events: { upcoming: true }, + }; + + await controller.query(queryDto, mockWorkspaceId); + + expect(mockService.query).toHaveBeenCalledWith({ + ...queryDto, + workspaceId: mockWorkspaceId, + }); + }); + + it("should handle query with search term", async () => { + const queryDto = { + workspaceId: mockWorkspaceId, + search: "important", + limit: 10, + }; + + await controller.query(queryDto, mockWorkspaceId); + + expect(mockService.query).toHaveBeenCalledWith({ + ...queryDto, + workspaceId: mockWorkspaceId, + }); + }); + + it("should return query result structure", async () => { + const result = await controller.query( + { workspaceId: mockWorkspaceId }, + mockWorkspaceId + ); + + expect(result).toHaveProperty("tasks"); + expect(result).toHaveProperty("events"); + expect(result).toHaveProperty("projects"); + expect(result).toHaveProperty("meta"); + expect(result.tasks).toHaveLength(1); + expect(result.events).toHaveLength(1); + expect(result.projects).toHaveLength(1); + }); + }); + + describe("getContext", () => { + it("should call service.getContext with merged workspaceId", async () => { + const contextDto = { + workspaceId: "different-id", + includeTasks: true, + }; + + const result = await controller.getContext(contextDto, mockWorkspaceId); + + expect(mockService.getContext).toHaveBeenCalledWith({ + ...contextDto, + workspaceId: mockWorkspaceId, + }); + expect(result).toEqual(mockContext); + }); + + it("should handle context with all options", async () => { + const contextDto = { + workspaceId: mockWorkspaceId, + includeTasks: true, + includeEvents: true, + includeProjects: true, + eventDays: 14, + }; + + await controller.getContext(contextDto, mockWorkspaceId); + + expect(mockService.getContext).toHaveBeenCalledWith({ + ...contextDto, + workspaceId: mockWorkspaceId, + }); + }); + + it("should return context structure", async () => { + const result = await controller.getContext( + { workspaceId: mockWorkspaceId }, + mockWorkspaceId + ); + + expect(result).toHaveProperty("timestamp"); + expect(result).toHaveProperty("workspace"); + expect(result).toHaveProperty("summary"); + expect(result.summary).toHaveProperty("activeTasks"); + expect(result.summary).toHaveProperty("overdueTasks"); + expect(result.summary).toHaveProperty("upcomingEvents"); + expect(result.summary).toHaveProperty("activeProjects"); + }); + + it("should include detailed lists when requested", async () => { + const result = await controller.getContext( + { + workspaceId: mockWorkspaceId, + includeTasks: true, + includeEvents: true, + includeProjects: true, + }, + mockWorkspaceId + ); + + expect(result.tasks).toBeDefined(); + expect(result.events).toBeDefined(); + expect(result.projects).toBeDefined(); + }); + }); + + describe("search", () => { + it("should call service.search with parameters", async () => { + const result = await controller.search("test query", "10", mockWorkspaceId); + + expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "test query", 10); + expect(result).toEqual(mockQueryResult); + }); + + it("should use default limit when not provided", async () => { + await controller.search("test", undefined as unknown as string, mockWorkspaceId); + + expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "test", 20); + }); + + it("should cap limit at 100", async () => { + await controller.search("test", "500", mockWorkspaceId); + + expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "test", 100); + }); + + it("should handle empty search term", async () => { + await controller.search(undefined as unknown as string, "10", mockWorkspaceId); + + expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "", 10); + }); + + it("should handle invalid limit", async () => { + await controller.search("test", "invalid", mockWorkspaceId); + + expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "test", 20); + }); + + it("should return search result structure", async () => { + const result = await controller.search("test", "10", mockWorkspaceId); + + expect(result).toHaveProperty("tasks"); + expect(result).toHaveProperty("events"); + expect(result).toHaveProperty("projects"); + expect(result).toHaveProperty("meta"); + }); + }); +}); diff --git a/apps/api/src/brain/brain.controller.ts b/apps/api/src/brain/brain.controller.ts new file mode 100644 index 0000000..f733429 --- /dev/null +++ b/apps/api/src/brain/brain.controller.ts @@ -0,0 +1,48 @@ +import { + Controller, + Get, + Post, + Body, + Query, + UseGuards, +} from "@nestjs/common"; +import { BrainService } from "./brain.service"; +import { BrainQueryDto, BrainContextDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, Permission, RequirePermission } from "../common/decorators"; + +@Controller("brain") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class BrainController { + constructor(private readonly brainService: BrainService) {} + + @Post("query") + @RequirePermission(Permission.WORKSPACE_ANY) + async query( + @Body() queryDto: BrainQueryDto, + @Workspace() workspaceId: string + ) { + return this.brainService.query({ ...queryDto, workspaceId }); + } + + @Get("context") + @RequirePermission(Permission.WORKSPACE_ANY) + async getContext( + @Query() contextDto: BrainContextDto, + @Workspace() workspaceId: string + ) { + return this.brainService.getContext({ ...contextDto, workspaceId }); + } + + @Get("search") + @RequirePermission(Permission.WORKSPACE_ANY) + async search( + @Query("q") searchTerm: string, + @Query("limit") limit: string, + @Workspace() workspaceId: string + ) { + const parsedLimit = limit ? Math.min(parseInt(limit, 10) || 20, 100) : 20; + return this.brainService.search(workspaceId, searchTerm || "", parsedLimit); + } +} diff --git a/apps/api/src/brain/brain.module.ts b/apps/api/src/brain/brain.module.ts new file mode 100644 index 0000000..a369f29 --- /dev/null +++ b/apps/api/src/brain/brain.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { BrainController } from "./brain.controller"; +import { BrainService } from "./brain.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; + +/** + * Brain module + * Provides unified query interface for agents to access workspace data + */ +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [BrainController], + providers: [BrainService], + exports: [BrainService], +}) +export class BrainModule {} diff --git a/apps/api/src/brain/brain.service.test.ts b/apps/api/src/brain/brain.service.test.ts new file mode 100644 index 0000000..12fe2f8 --- /dev/null +++ b/apps/api/src/brain/brain.service.test.ts @@ -0,0 +1,507 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { BrainService } from "./brain.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { TaskStatus, TaskPriority, ProjectStatus, EntityType } from "@prisma/client"; + +describe("BrainService", () => { + let service: BrainService; + let mockPrisma: { + task: { + findMany: ReturnType; + count: ReturnType; + }; + event: { + findMany: ReturnType; + count: ReturnType; + }; + project: { + findMany: ReturnType; + count: ReturnType; + }; + workspace: { + findUniqueOrThrow: ReturnType; + }; + }; + + const mockWorkspaceId = "123e4567-e89b-12d3-a456-426614174000"; + + const mockTasks = [ + { + id: "task-1", + title: "Test Task 1", + description: "Description 1", + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + dueDate: new Date("2025-02-01"), + assignee: { id: "user-1", name: "John Doe", email: "john@example.com" }, + project: { id: "project-1", name: "Project 1", color: "#ff0000" }, + }, + { + id: "task-2", + title: "Test Task 2", + description: null, + status: TaskStatus.NOT_STARTED, + priority: TaskPriority.MEDIUM, + dueDate: null, + assignee: null, + project: null, + }, + ]; + + const mockEvents = [ + { + id: "event-1", + title: "Test Event 1", + description: "Event description", + startTime: new Date("2025-02-01T10:00:00Z"), + endTime: new Date("2025-02-01T11:00:00Z"), + allDay: false, + location: "Conference Room A", + project: { id: "project-1", name: "Project 1", color: "#ff0000" }, + }, + ]; + + const mockProjects = [ + { + id: "project-1", + name: "Project 1", + description: "Project description", + status: ProjectStatus.ACTIVE, + startDate: new Date("2025-01-01"), + endDate: new Date("2025-06-30"), + color: "#ff0000", + _count: { tasks: 5, events: 3 }, + }, + ]; + + beforeEach(() => { + mockPrisma = { + task: { + findMany: vi.fn().mockResolvedValue(mockTasks), + count: vi.fn().mockResolvedValue(10), + }, + event: { + findMany: vi.fn().mockResolvedValue(mockEvents), + count: vi.fn().mockResolvedValue(5), + }, + project: { + findMany: vi.fn().mockResolvedValue(mockProjects), + count: vi.fn().mockResolvedValue(3), + }, + workspace: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ + id: mockWorkspaceId, + name: "Test Workspace", + }), + }, + }; + + service = new BrainService(mockPrisma as unknown as PrismaService); + }); + + describe("query", () => { + it("should query all entity types by default", async () => { + const result = await service.query({ + workspaceId: mockWorkspaceId, + }); + + expect(result.tasks).toHaveLength(2); + expect(result.events).toHaveLength(1); + expect(result.projects).toHaveLength(1); + expect(result.meta.totalTasks).toBe(2); + expect(result.meta.totalEvents).toBe(1); + expect(result.meta.totalProjects).toBe(1); + }); + + it("should query only specified entity types", async () => { + const result = await service.query({ + workspaceId: mockWorkspaceId, + entities: [EntityType.TASK], + }); + + expect(result.tasks).toHaveLength(2); + expect(result.events).toHaveLength(0); + expect(result.projects).toHaveLength(0); + expect(mockPrisma.task.findMany).toHaveBeenCalled(); + expect(mockPrisma.event.findMany).not.toHaveBeenCalled(); + expect(mockPrisma.project.findMany).not.toHaveBeenCalled(); + }); + + it("should apply task filters", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + tasks: { + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + }, + }); + + expect(mockPrisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: mockWorkspaceId, + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + }), + }) + ); + }); + + it("should apply task statuses filter (array)", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + tasks: { + statuses: [TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS], + }, + }); + + expect(mockPrisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { in: [TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS] }, + }), + }) + ); + }); + + it("should apply overdue filter", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + tasks: { + overdue: true, + }, + }); + + expect(mockPrisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + dueDate: expect.objectContaining({ lt: expect.any(Date) }), + status: { in: [TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS] }, + }), + }) + ); + }); + + it("should apply unassigned filter", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + tasks: { + unassigned: true, + }, + }); + + expect(mockPrisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + assigneeId: null, + }), + }) + ); + }); + + it("should apply due date range filter", async () => { + const dueDateFrom = new Date("2025-01-01"); + const dueDateTo = new Date("2025-01-31"); + + await service.query({ + workspaceId: mockWorkspaceId, + tasks: { + dueDateFrom, + dueDateTo, + }, + }); + + expect(mockPrisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + dueDate: { gte: dueDateFrom, lte: dueDateTo }, + }), + }) + ); + }); + + it("should apply event filters", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + events: { + allDay: true, + upcoming: true, + }, + }); + + expect(mockPrisma.event.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + allDay: true, + startTime: { gte: expect.any(Date) }, + }), + }) + ); + }); + + it("should apply event date range filter", async () => { + const startFrom = new Date("2025-02-01"); + const startTo = new Date("2025-02-28"); + + await service.query({ + workspaceId: mockWorkspaceId, + events: { + startFrom, + startTo, + }, + }); + + expect(mockPrisma.event.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + startTime: { gte: startFrom, lte: startTo }, + }), + }) + ); + }); + + it("should apply project filters", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + projects: { + status: ProjectStatus.ACTIVE, + }, + }); + + expect(mockPrisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: ProjectStatus.ACTIVE, + }), + }) + ); + }); + + it("should apply project statuses filter (array)", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + projects: { + statuses: [ProjectStatus.PLANNING, ProjectStatus.ACTIVE], + }, + }); + + expect(mockPrisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { in: [ProjectStatus.PLANNING, ProjectStatus.ACTIVE] }, + }), + }) + ); + }); + + it("should apply search term across tasks", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + search: "test", + entities: [EntityType.TASK], + }); + + expect(mockPrisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { title: { contains: "test", mode: "insensitive" } }, + { description: { contains: "test", mode: "insensitive" } }, + ], + }), + }) + ); + }); + + it("should apply search term across events", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + search: "conference", + entities: [EntityType.EVENT], + }); + + expect(mockPrisma.event.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { title: { contains: "conference", mode: "insensitive" } }, + { description: { contains: "conference", mode: "insensitive" } }, + { location: { contains: "conference", mode: "insensitive" } }, + ], + }), + }) + ); + }); + + it("should apply search term across projects", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + search: "project", + entities: [EntityType.PROJECT], + }); + + expect(mockPrisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { name: { contains: "project", mode: "insensitive" } }, + { description: { contains: "project", mode: "insensitive" } }, + ], + }), + }) + ); + }); + + it("should respect limit parameter", async () => { + await service.query({ + workspaceId: mockWorkspaceId, + limit: 5, + }); + + expect(mockPrisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 5, + }) + ); + }); + + it("should include query and filters in meta", async () => { + const result = await service.query({ + workspaceId: mockWorkspaceId, + query: "What tasks are due?", + tasks: { status: TaskStatus.IN_PROGRESS }, + }); + + expect(result.meta.query).toBe("What tasks are due?"); + expect(result.meta.filters.tasks).toEqual({ status: TaskStatus.IN_PROGRESS }); + }); + }); + + describe("getContext", () => { + it("should return context with summary", async () => { + const result = await service.getContext({ + workspaceId: mockWorkspaceId, + }); + + expect(result.timestamp).toBeInstanceOf(Date); + expect(result.workspace.id).toBe(mockWorkspaceId); + expect(result.workspace.name).toBe("Test Workspace"); + expect(result.summary).toEqual({ + activeTasks: 10, + overdueTasks: 10, + upcomingEvents: 5, + activeProjects: 3, + }); + }); + + it("should include tasks when requested", async () => { + const result = await service.getContext({ + workspaceId: mockWorkspaceId, + includeTasks: true, + }); + + expect(result.tasks).toBeDefined(); + expect(result.tasks).toHaveLength(2); + expect(result.tasks![0].isOverdue).toBeDefined(); + }); + + it("should include events when requested", async () => { + const result = await service.getContext({ + workspaceId: mockWorkspaceId, + includeEvents: true, + }); + + expect(result.events).toBeDefined(); + expect(result.events).toHaveLength(1); + }); + + it("should include projects when requested", async () => { + const result = await service.getContext({ + workspaceId: mockWorkspaceId, + includeProjects: true, + }); + + expect(result.projects).toBeDefined(); + expect(result.projects).toHaveLength(1); + expect(result.projects![0].taskCount).toBeDefined(); + }); + + it("should use custom eventDays", async () => { + await service.getContext({ + workspaceId: mockWorkspaceId, + eventDays: 14, + }); + + expect(mockPrisma.event.count).toHaveBeenCalled(); + expect(mockPrisma.event.findMany).toHaveBeenCalled(); + }); + + it("should not include tasks when explicitly disabled", async () => { + const result = await service.getContext({ + workspaceId: mockWorkspaceId, + includeTasks: false, + includeEvents: true, + includeProjects: true, + }); + + expect(result.tasks).toBeUndefined(); + expect(result.events).toBeDefined(); + expect(result.projects).toBeDefined(); + }); + + it("should not include events when explicitly disabled", async () => { + const result = await service.getContext({ + workspaceId: mockWorkspaceId, + includeTasks: true, + includeEvents: false, + includeProjects: true, + }); + + expect(result.tasks).toBeDefined(); + expect(result.events).toBeUndefined(); + expect(result.projects).toBeDefined(); + }); + + it("should not include projects when explicitly disabled", async () => { + const result = await service.getContext({ + workspaceId: mockWorkspaceId, + includeTasks: true, + includeEvents: true, + includeProjects: false, + }); + + expect(result.tasks).toBeDefined(); + expect(result.events).toBeDefined(); + expect(result.projects).toBeUndefined(); + }); + }); + + describe("search", () => { + it("should search across all entities", async () => { + const result = await service.search(mockWorkspaceId, "test"); + + expect(result.tasks).toHaveLength(2); + expect(result.events).toHaveLength(1); + expect(result.projects).toHaveLength(1); + expect(result.meta.query).toBe("test"); + }); + + it("should respect limit parameter", async () => { + await service.search(mockWorkspaceId, "test", 5); + + expect(mockPrisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 5, + }) + ); + }); + + it("should handle empty search term", async () => { + const result = await service.search(mockWorkspaceId, ""); + + expect(result.tasks).toBeDefined(); + expect(result.events).toBeDefined(); + expect(result.projects).toBeDefined(); + }); + }); +}); diff --git a/apps/api/src/brain/brain.service.ts b/apps/api/src/brain/brain.service.ts new file mode 100644 index 0000000..db670fc --- /dev/null +++ b/apps/api/src/brain/brain.service.ts @@ -0,0 +1,374 @@ +import { Injectable } from "@nestjs/common"; +import { EntityType, TaskStatus, ProjectStatus } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import type { BrainQueryDto, BrainContextDto, TaskFilter, EventFilter, ProjectFilter } from "./dto"; + +export interface BrainQueryResult { + tasks: Array<{ + id: string; + title: string; + description: string | null; + status: TaskStatus; + priority: string; + dueDate: Date | null; + assignee: { id: string; name: string; email: string } | null; + project: { id: string; name: string; color: string | null } | null; + }>; + events: Array<{ + id: string; + title: string; + description: string | null; + startTime: Date; + endTime: Date | null; + allDay: boolean; + location: string | null; + project: { id: string; name: string; color: string | null } | null; + }>; + projects: Array<{ + id: string; + name: string; + description: string | null; + status: ProjectStatus; + startDate: Date | null; + endDate: Date | null; + color: string | null; + _count: { tasks: number; events: number }; + }>; + meta: { + totalTasks: number; + totalEvents: number; + totalProjects: number; + query?: string; + filters: { + tasks?: TaskFilter; + events?: EventFilter; + projects?: ProjectFilter; + }; + }; +} + +export interface BrainContext { + timestamp: Date; + workspace: { id: string; name: string }; + summary: { + activeTasks: number; + overdueTasks: number; + upcomingEvents: number; + activeProjects: number; + }; + tasks?: Array<{ + id: string; + title: string; + status: TaskStatus; + priority: string; + dueDate: Date | null; + isOverdue: boolean; + }>; + events?: Array<{ + id: string; + title: string; + startTime: Date; + endTime: Date | null; + allDay: boolean; + location: string | null; + }>; + projects?: Array<{ + id: string; + name: string; + status: ProjectStatus; + taskCount: number; + }>; +} + +@Injectable() +export class BrainService { + constructor(private readonly prisma: PrismaService) {} + + async query(queryDto: BrainQueryDto): Promise { + const { workspaceId, entities, search, limit = 20 } = queryDto; + const includeEntities = entities || [EntityType.TASK, EntityType.EVENT, EntityType.PROJECT]; + const includeTasks = includeEntities.includes(EntityType.TASK); + const includeEvents = includeEntities.includes(EntityType.EVENT); + const includeProjects = includeEntities.includes(EntityType.PROJECT); + + const [tasks, events, projects] = await Promise.all([ + includeTasks ? this.queryTasks(workspaceId, queryDto.tasks, search, limit) : [], + includeEvents ? this.queryEvents(workspaceId, queryDto.events, search, limit) : [], + includeProjects ? this.queryProjects(workspaceId, queryDto.projects, search, limit) : [], + ]); + + return { + tasks, + events, + projects, + meta: { + totalTasks: tasks.length, + totalEvents: events.length, + totalProjects: projects.length, + query: queryDto.query, + filters: { + tasks: queryDto.tasks, + events: queryDto.events, + projects: queryDto.projects, + }, + }, + }; + } + + async getContext(contextDto: BrainContextDto): Promise { + const { + workspaceId, + includeTasks = true, + includeEvents = true, + includeProjects = true, + eventDays = 7, + } = contextDto; + + const now = new Date(); + const futureDate = new Date(now); + futureDate.setDate(futureDate.getDate() + eventDays); + + const workspace = await this.prisma.workspace.findUniqueOrThrow({ + where: { id: workspaceId }, + select: { id: true, name: true }, + }); + + const [activeTaskCount, overdueTaskCount, upcomingEventCount, activeProjectCount] = await Promise.all([ + this.prisma.task.count({ + where: { workspaceId, status: { in: [TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS] } }, + }), + this.prisma.task.count({ + where: { + workspaceId, + status: { in: [TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS] }, + dueDate: { lt: now }, + }, + }), + this.prisma.event.count({ + where: { workspaceId, startTime: { gte: now, lte: futureDate } }, + }), + this.prisma.project.count({ + where: { workspaceId, status: { in: [ProjectStatus.PLANNING, ProjectStatus.ACTIVE] } }, + }), + ]); + + const context: BrainContext = { + timestamp: now, + workspace, + summary: { + activeTasks: activeTaskCount, + overdueTasks: overdueTaskCount, + upcomingEvents: upcomingEventCount, + activeProjects: activeProjectCount, + }, + }; + + if (includeTasks) { + const tasks = await this.prisma.task.findMany({ + where: { workspaceId, status: { in: [TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS] } }, + select: { id: true, title: true, status: true, priority: true, dueDate: true }, + orderBy: [{ priority: "desc" }, { dueDate: "asc" }], + take: 20, + }); + context.tasks = tasks.map((task) => ({ + ...task, + isOverdue: task.dueDate ? task.dueDate < now : false, + })); + } + + if (includeEvents) { + context.events = await this.prisma.event.findMany({ + where: { workspaceId, startTime: { gte: now, lte: futureDate } }, + select: { id: true, title: true, startTime: true, endTime: true, allDay: true, location: true }, + orderBy: { startTime: "asc" }, + take: 20, + }); + } + + if (includeProjects) { + const projects = await this.prisma.project.findMany({ + where: { workspaceId, status: { in: [ProjectStatus.PLANNING, ProjectStatus.ACTIVE] } }, + select: { id: true, name: true, status: true, _count: { select: { tasks: true } } }, + orderBy: { updatedAt: "desc" }, + take: 10, + }); + context.projects = projects.map((p) => ({ + id: p.id, + name: p.name, + status: p.status, + taskCount: p._count.tasks, + })); + } + + return context; + } + + async search(workspaceId: string, searchTerm: string, limit: number = 20): Promise { + const [tasks, events, projects] = await Promise.all([ + this.queryTasks(workspaceId, undefined, searchTerm, limit), + this.queryEvents(workspaceId, undefined, searchTerm, limit), + this.queryProjects(workspaceId, undefined, searchTerm, limit), + ]); + + return { + tasks, + events, + projects, + meta: { + totalTasks: tasks.length, + totalEvents: events.length, + totalProjects: projects.length, + query: searchTerm, + filters: {}, + }, + }; + } + + private async queryTasks( + workspaceId: string, + filter?: TaskFilter, + search?: string, + limit: number = 20 + ): Promise { + const where: Record = { workspaceId }; + const now = new Date(); + + if (filter) { + if (filter.status) { + where.status = filter.status; + } else if (filter.statuses && filter.statuses.length > 0) { + where.status = { in: filter.statuses }; + } + if (filter.priority) { + where.priority = filter.priority; + } else if (filter.priorities && filter.priorities.length > 0) { + where.priority = { in: filter.priorities }; + } + if (filter.assigneeId) where.assigneeId = filter.assigneeId; + if (filter.unassigned) where.assigneeId = null; + if (filter.projectId) where.projectId = filter.projectId; + if (filter.dueDateFrom || filter.dueDateTo) { + where.dueDate = {}; + if (filter.dueDateFrom) (where.dueDate as Record).gte = filter.dueDateFrom; + if (filter.dueDateTo) (where.dueDate as Record).lte = filter.dueDateTo; + } + if (filter.overdue) { + where.dueDate = { lt: now }; + where.status = { in: [TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS] }; + } + } + + if (search) { + where.OR = [ + { title: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + ]; + } + + return this.prisma.task.findMany({ + where, + select: { + id: true, + title: true, + description: true, + status: true, + priority: true, + dueDate: true, + assignee: { select: { id: true, name: true, email: true } }, + project: { select: { id: true, name: true, color: true } }, + }, + orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { createdAt: "desc" }], + take: limit, + }); + } + + private async queryEvents( + workspaceId: string, + filter?: EventFilter, + search?: string, + limit: number = 20 + ): Promise { + const where: Record = { workspaceId }; + const now = new Date(); + + if (filter) { + if (filter.projectId) where.projectId = filter.projectId; + if (filter.allDay !== undefined) where.allDay = filter.allDay; + if (filter.startFrom || filter.startTo) { + where.startTime = {}; + if (filter.startFrom) (where.startTime as Record).gte = filter.startFrom; + if (filter.startTo) (where.startTime as Record).lte = filter.startTo; + } + if (filter.upcoming) where.startTime = { gte: now }; + } + + if (search) { + where.OR = [ + { title: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + { location: { contains: search, mode: "insensitive" } }, + ]; + } + + return this.prisma.event.findMany({ + where, + select: { + id: true, + title: true, + description: true, + startTime: true, + endTime: true, + allDay: true, + location: true, + project: { select: { id: true, name: true, color: true } }, + }, + orderBy: { startTime: "asc" }, + take: limit, + }); + } + + private async queryProjects( + workspaceId: string, + filter?: ProjectFilter, + search?: string, + limit: number = 20 + ): Promise { + const where: Record = { workspaceId }; + + if (filter) { + if (filter.status) { + where.status = filter.status; + } else if (filter.statuses && filter.statuses.length > 0) { + where.status = { in: filter.statuses }; + } + if (filter.startDateFrom || filter.startDateTo) { + where.startDate = {}; + if (filter.startDateFrom) (where.startDate as Record).gte = filter.startDateFrom; + if (filter.startDateTo) (where.startDate as Record).lte = filter.startDateTo; + } + } + + if (search) { + where.OR = [ + { name: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + ]; + } + + return this.prisma.project.findMany({ + where, + select: { + id: true, + name: true, + description: true, + status: true, + startDate: true, + endDate: true, + color: true, + _count: { select: { tasks: true, events: true } }, + }, + orderBy: { updatedAt: "desc" }, + take: limit, + }); + } +} diff --git a/apps/api/src/brain/dto/brain-query.dto.ts b/apps/api/src/brain/dto/brain-query.dto.ts new file mode 100644 index 0000000..1ec56f7 --- /dev/null +++ b/apps/api/src/brain/dto/brain-query.dto.ts @@ -0,0 +1,164 @@ +import { TaskStatus, TaskPriority, ProjectStatus, EntityType } from "@prisma/client"; +import { + IsUUID, + IsEnum, + IsOptional, + IsString, + IsInt, + Min, + Max, + IsDateString, + IsArray, + ValidateNested, + IsBoolean, +} from "class-validator"; +import { Type } from "class-transformer"; + +export class TaskFilter { + @IsOptional() + @IsEnum(TaskStatus, { message: "status must be a valid TaskStatus" }) + status?: TaskStatus; + + @IsOptional() + @IsArray() + @IsEnum(TaskStatus, { each: true, message: "statuses must be valid TaskStatus values" }) + statuses?: TaskStatus[]; + + @IsOptional() + @IsEnum(TaskPriority, { message: "priority must be a valid TaskPriority" }) + priority?: TaskPriority; + + @IsOptional() + @IsArray() + @IsEnum(TaskPriority, { each: true, message: "priorities must be valid TaskPriority values" }) + priorities?: TaskPriority[]; + + @IsOptional() + @IsUUID("4", { message: "assigneeId must be a valid UUID" }) + assigneeId?: string; + + @IsOptional() + @IsUUID("4", { message: "projectId must be a valid UUID" }) + projectId?: string; + + @IsOptional() + @IsDateString({}, { message: "dueDateFrom must be a valid ISO 8601 date string" }) + dueDateFrom?: Date; + + @IsOptional() + @IsDateString({}, { message: "dueDateTo must be a valid ISO 8601 date string" }) + dueDateTo?: Date; + + @IsOptional() + @IsBoolean() + overdue?: boolean; + + @IsOptional() + @IsBoolean() + unassigned?: boolean; +} + +export class EventFilter { + @IsOptional() + @IsUUID("4", { message: "projectId must be a valid UUID" }) + projectId?: string; + + @IsOptional() + @IsDateString({}, { message: "startFrom must be a valid ISO 8601 date string" }) + startFrom?: Date; + + @IsOptional() + @IsDateString({}, { message: "startTo must be a valid ISO 8601 date string" }) + startTo?: Date; + + @IsOptional() + @IsBoolean() + allDay?: boolean; + + @IsOptional() + @IsBoolean() + upcoming?: boolean; +} + +export class ProjectFilter { + @IsOptional() + @IsEnum(ProjectStatus, { message: "status must be a valid ProjectStatus" }) + status?: ProjectStatus; + + @IsOptional() + @IsArray() + @IsEnum(ProjectStatus, { each: true, message: "statuses must be valid ProjectStatus values" }) + statuses?: ProjectStatus[]; + + @IsOptional() + @IsDateString({}, { message: "startDateFrom must be a valid ISO 8601 date string" }) + startDateFrom?: Date; + + @IsOptional() + @IsDateString({}, { message: "startDateTo must be a valid ISO 8601 date string" }) + startDateTo?: Date; +} + +export class BrainQueryDto { + @IsUUID("4", { message: "workspaceId must be a valid UUID" }) + workspaceId!: string; + + @IsOptional() + @IsString() + query?: string; + + @IsOptional() + @IsArray() + @IsEnum(EntityType, { each: true, message: "entities must be valid EntityType values" }) + entities?: EntityType[]; + + @IsOptional() + @ValidateNested() + @Type(() => TaskFilter) + tasks?: TaskFilter; + + @IsOptional() + @ValidateNested() + @Type(() => EventFilter) + events?: EventFilter; + + @IsOptional() + @ValidateNested() + @Type(() => ProjectFilter) + projects?: ProjectFilter; + + @IsOptional() + @IsString() + search?: string; + + @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; +} + +export class BrainContextDto { + @IsUUID("4", { message: "workspaceId must be a valid UUID" }) + workspaceId!: string; + + @IsOptional() + @IsBoolean() + includeEvents?: boolean; + + @IsOptional() + @IsBoolean() + includeTasks?: boolean; + + @IsOptional() + @IsBoolean() + includeProjects?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(30) + eventDays?: number; +} diff --git a/apps/api/src/brain/dto/index.ts b/apps/api/src/brain/dto/index.ts new file mode 100644 index 0000000..07aac89 --- /dev/null +++ b/apps/api/src/brain/dto/index.ts @@ -0,0 +1 @@ +export { BrainQueryDto, TaskFilter, EventFilter, ProjectFilter, BrainContextDto } from "./brain-query.dto";