feat(#22): implement brain query API
- Create brain module with service, controller, and DTOs - POST /api/brain/query - Structured queries for tasks, events, projects - GET /api/brain/context - Get current workspace context for agents - GET /api/brain/search - Search across all entities - Support filters: status, priority, date ranges, assignee, etc. - 41 tests covering service (27) and controller (14) - Integrated with AuthGuard, WorkspaceGuard, PermissionGuard
This commit is contained in:
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user