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 { 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 { 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 { 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 { const where: Record = { 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).gte = filter.dueDateFrom; if (filter.dueDateTo) (where.dueDate as Record).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 { const where: Record = { 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).gte = filter.startFrom; if (filter.startTo) (where.startTime as Record).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 { const where: Record = { 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).gte = filter.startDateFrom; if (filter.startDateTo) (where.startDate as Record).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, }); } }