Files
stack/apps/api/src/brain/brain.service.ts
Jason Woltje 17cfeb974b
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(SEC-API-19+20): Validate brain search length and limit params
- Add @MaxLength(500) to BrainQueryDto.query and BrainQueryDto.search fields
- Create BrainSearchDto with validated q (max 500 chars) and limit (1-100) fields
- Update BrainController.search to use BrainSearchDto instead of raw query params
- Add defensive validation in BrainService.search and BrainService.query methods:
  - Reject search terms exceeding 500 characters with BadRequestException
  - Clamp limit to valid range [1, 100] for defense-in-depth
- Add comprehensive tests for DTO validation and service-level guards
- Update existing controller tests for new search method signature

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 13:29:03 -06:00

457 lines
14 KiB
TypeScript

import { Injectable, BadRequestException } 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: {
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: {
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: {
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?: {
id: string;
title: string;
status: TaskStatus;
priority: string;
dueDate: Date | null;
isOverdue: boolean;
}[];
events?: {
id: string;
title: string;
startTime: Date;
endTime: Date | null;
allDay: boolean;
location: string | null;
}[];
projects?: {
id: string;
name: string;
status: ProjectStatus;
taskCount: number;
}[];
}
/** Maximum allowed length for search query strings */
const MAX_SEARCH_LENGTH = 500;
/** Maximum allowed limit for search results per entity type */
const MAX_SEARCH_LIMIT = 100;
/**
* @description Service for querying and aggregating workspace data for AI/brain operations.
* Provides unified access to tasks, events, and projects with filtering and search capabilities.
*/
@Injectable()
export class BrainService {
constructor(private readonly prisma: PrismaService) {}
/**
* @description Query workspace entities with flexible filtering options.
* Retrieves tasks, events, and/or projects based on specified criteria.
* @param queryDto - Query parameters including workspaceId, entity types, filters, and search term
* @returns Filtered tasks, events, and projects with metadata about the query
* @throws PrismaClientKnownRequestError if database query fails
*/
async query(queryDto: BrainQueryDto): Promise<BrainQueryResult> {
const { workspaceId, entities, search, limit = 20 } = queryDto;
if (search && search.length > MAX_SEARCH_LENGTH) {
throw new BadRequestException(
`Search term must not exceed ${String(MAX_SEARCH_LENGTH)} characters`
);
}
if (queryDto.query && queryDto.query.length > MAX_SEARCH_LENGTH) {
throw new BadRequestException(
`Query must not exceed ${String(MAX_SEARCH_LENGTH)} characters`
);
}
const clampedLimit = Math.max(1, Math.min(limit, MAX_SEARCH_LIMIT));
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, clampedLimit) : [],
includeEvents ? this.queryEvents(workspaceId, queryDto.events, search, clampedLimit) : [],
includeProjects
? this.queryProjects(workspaceId, queryDto.projects, search, clampedLimit)
: [],
]);
// Build filters object conditionally for exactOptionalPropertyTypes
const filters: { tasks?: TaskFilter; events?: EventFilter; projects?: ProjectFilter } = {};
if (queryDto.tasks !== undefined) {
filters.tasks = queryDto.tasks;
}
if (queryDto.events !== undefined) {
filters.events = queryDto.events;
}
if (queryDto.projects !== undefined) {
filters.projects = queryDto.projects;
}
// Build meta object conditionally for exactOptionalPropertyTypes
const meta: {
totalTasks: number;
totalEvents: number;
totalProjects: number;
query?: string;
filters: { tasks?: TaskFilter; events?: EventFilter; projects?: ProjectFilter };
} = {
totalTasks: tasks.length,
totalEvents: events.length,
totalProjects: projects.length,
filters,
};
if (queryDto.query !== undefined) {
meta.query = queryDto.query;
}
return {
tasks,
events,
projects,
meta,
};
}
/**
* @description Get current workspace context for AI operations.
* Provides a summary of active tasks, overdue items, upcoming events, and projects.
* @param contextDto - Context options including workspaceId and which entities to include
* @returns Workspace context with summary counts and optional detailed entity lists
* @throws NotFoundError if workspace does not exist
* @throws PrismaClientKnownRequestError if database query fails
*/
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;
}
/**
* @description Search across all workspace entities by text.
* Performs case-insensitive search on titles, descriptions, and locations.
* @param workspaceId - The workspace to search within
* @param searchTerm - Text to search for across all entity types
* @param limit - Maximum number of results per entity type (default: 20)
* @returns Matching tasks, events, and projects with metadata
* @throws PrismaClientKnownRequestError if database query fails
*/
async search(workspaceId: string, searchTerm: string, limit = 20): Promise<BrainQueryResult> {
if (searchTerm.length > MAX_SEARCH_LENGTH) {
throw new BadRequestException(
`Search term must not exceed ${String(MAX_SEARCH_LENGTH)} characters`
);
}
const clampedLimit = Math.max(1, Math.min(limit, MAX_SEARCH_LIMIT));
const [tasks, events, projects] = await Promise.all([
this.queryTasks(workspaceId, undefined, searchTerm, clampedLimit),
this.queryEvents(workspaceId, undefined, searchTerm, clampedLimit),
this.queryProjects(workspaceId, undefined, searchTerm, clampedLimit),
]);
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 = 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 = 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 = 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,
});
}
}