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:
Jason Woltje
2026-01-29 19:40:30 -06:00
parent 1cb54b56b0
commit 1bd21b33d7
8 changed files with 1392 additions and 0 deletions

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