All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
457 lines
14 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|