Merge pull request 'feat(#22): Implement brain query API endpoint' (#108) from feature/22-brain-api into develop

Reviewed-on: #108
This commit was merged in pull request #108.
This commit is contained in:
2026-01-30 01:45:59 +00:00
8 changed files with 1392 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import { KnowledgeModule } from "./knowledge/knowledge.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
import { WebSocketModule } from "./websocket/websocket.module"; import { WebSocketModule } from "./websocket/websocket.module";
import { LlmModule } from "./llm/llm.module"; import { LlmModule } from "./llm/llm.module";
import { BrainModule } from "./brain/brain.module";
@Module({ @Module({
imports: [ imports: [
@@ -34,6 +35,7 @@ import { LlmModule } from "./llm/llm.module";
UsersModule, UsersModule,
WebSocketModule, WebSocketModule,
LlmModule, LlmModule,
BrainModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@@ -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<typeof vi.fn>;
getContext: ReturnType<typeof vi.fn>;
search: ReturnType<typeof vi.fn>;
};
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");
});
});
});

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
event: {
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
project: {
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
workspace: {
findUniqueOrThrow: ReturnType<typeof vi.fn>;
};
};
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();
});
});
});

View File

@@ -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<BrainQueryResult> {
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<BrainContext> {
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<BrainQueryResult> {
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<BrainQueryResult["tasks"]> {
const where: Record<string, unknown> = { 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<string, unknown>).gte = filter.dueDateFrom;
if (filter.dueDateTo) (where.dueDate as Record<string, unknown>).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<BrainQueryResult["events"]> {
const where: Record<string, unknown> = { 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<string, unknown>).gte = filter.startFrom;
if (filter.startTo) (where.startTime as Record<string, unknown>).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<BrainQueryResult["projects"]> {
const where: Record<string, unknown> = { 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<string, unknown>).gte = filter.startDateFrom;
if (filter.startDateTo) (where.startDate as Record<string, unknown>).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,
});
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export { BrainQueryDto, TaskFilter, EventFilter, ProjectFilter, BrainContextDto } from "./brain-query.dto";