feat(#22): Implement brain query API endpoint #108
@@ -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],
|
||||
|
||||
279
apps/api/src/brain/brain.controller.test.ts
Normal file
279
apps/api/src/brain/brain.controller.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
48
apps/api/src/brain/brain.controller.ts
Normal file
48
apps/api/src/brain/brain.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
apps/api/src/brain/brain.module.ts
Normal file
17
apps/api/src/brain/brain.module.ts
Normal 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 {}
|
||||
507
apps/api/src/brain/brain.service.test.ts
Normal file
507
apps/api/src/brain/brain.service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
374
apps/api/src/brain/brain.service.ts
Normal file
374
apps/api/src/brain/brain.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
164
apps/api/src/brain/dto/brain-query.dto.ts
Normal file
164
apps/api/src/brain/dto/brain-query.dto.ts
Normal 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;
|
||||
}
|
||||
1
apps/api/src/brain/dto/index.ts
Normal file
1
apps/api/src/brain/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BrainQueryDto, TaskFilter, EventFilter, ProjectFilter, BrainContextDto } from "./brain-query.dto";
|
||||
Reference in New Issue
Block a user