TaskService.ts•23 kB
import { v4 as uuidv4 } from 'uuid';
import { TaskRepository, TaskData } from '../repositories/TaskRepository.js';
import { ProjectRepository } from '../repositories/ProjectRepository.js'; // Needed to check project existence
import { logger } from '../utils/logger.js';
import { NotFoundError, ValidationError } from '../utils/errors.js'; // Using custom errors
// Define the input structure for adding a task, based on feature spec
export interface AddTaskInput {
    project_id: string;
    description: string;
    dependencies?: string[];
    priority?: 'high' | 'medium' | 'low';
    status?: 'todo' | 'in-progress' | 'review' | 'done';
}
// Options for listing tasks
export interface ListTasksOptions {
    project_id: string;
    status?: TaskData['status'];
    include_subtasks?: boolean;
}
// Type for task data potentially including nested subtasks
export interface StructuredTaskData extends TaskData {
    subtasks?: StructuredTaskData[];
}
// Type for full task details including dependencies and subtasks
export interface FullTaskData extends TaskData {
    dependencies: string[];
    subtasks: TaskData[]; // For V1 showTask, just return direct subtasks without their own nesting/deps
}
// Input for expanding a task
export interface ExpandTaskInput {
    project_id: string;
    task_id: string; // Parent task ID
    subtask_descriptions: string[];
    force?: boolean;
}
import { Database as Db } from 'better-sqlite3'; // Import Db type
import { ConflictError } from '../utils/errors.js'; // Import ConflictError
export class TaskService {
    private taskRepository: TaskRepository;
    private projectRepository: ProjectRepository;
    private db: Db; // Add db instance
    constructor(
        db: Db, // Inject Db instance
        taskRepository: TaskRepository,
        projectRepository: ProjectRepository
    ) {
        this.db = db; // Store db instance
        this.taskRepository = taskRepository;
        this.projectRepository = projectRepository;
    }
    /**
     * Adds a new task to a specified project.
     */
    public async addTask(input: AddTaskInput): Promise<TaskData> {
        logger.info(`[TaskService] Attempting to add task to project: ${input.project_id}`);
        const projectExists = this.projectRepository.findById(input.project_id);
        if (!projectExists) {
            logger.warn(`[TaskService] Project not found: ${input.project_id}`);
            throw new NotFoundError(`Project with ID ${input.project_id} not found.`);
        }
        const taskId = uuidv4();
        const now = new Date().toISOString();
        const newTaskData: TaskData = {
            task_id: taskId,
            project_id: input.project_id,
            parent_task_id: null,
            description: input.description,
            status: input.status ?? 'todo',
            priority: input.priority ?? 'medium',
            created_at: now,
            updated_at: now,
        };
        // TODO: Validate Dependency Existence
        try {
            this.taskRepository.create(newTaskData, input.dependencies);
            logger.info(`[TaskService] Successfully added task ${taskId} to project ${input.project_id}`);
            return newTaskData;
        } catch (error) {
            logger.error(`[TaskService] Error adding task to project ${input.project_id}:`, error);
            throw error;
        }
    }
    /**
     * Lists tasks for a project.
     */
    public async listTasks(options: ListTasksOptions): Promise<TaskData[] | StructuredTaskData[]> {
        logger.info(`[TaskService] Attempting to list tasks for project: ${options.project_id}`, options);
        const projectExists = this.projectRepository.findById(options.project_id);
        if (!projectExists) {
            logger.warn(`[TaskService] Project not found: ${options.project_id}`);
            throw new NotFoundError(`Project with ID ${options.project_id} not found.`);
        }
        try {
            const allTasks = this.taskRepository.findByProjectId(options.project_id, options.status);
            if (!options.include_subtasks) {
                const topLevelTasks = allTasks.filter(task => !task.parent_task_id);
                logger.info(`[TaskService] Found ${topLevelTasks.length} top-level tasks for project ${options.project_id}`);
                return topLevelTasks;
            } else {
                const taskMap: Map<string, StructuredTaskData> = new Map();
                const rootTasks: StructuredTaskData[] = [];
                for (const task of allTasks) {
                    taskMap.set(task.task_id, { ...task, subtasks: [] });
                }
                for (const task of allTasks) {
                    if (task.parent_task_id && taskMap.has(task.parent_task_id)) {
                        const parent = taskMap.get(task.parent_task_id)!;
                        parent.subtasks!.push(taskMap.get(task.task_id)!);
                    } else if (!task.parent_task_id) {
                        rootTasks.push(taskMap.get(task.task_id)!);
                    }
                }
                logger.info(`[TaskService] Found ${rootTasks.length} structured root tasks for project ${options.project_id}`);
                return rootTasks;
            }
        } catch (error) {
            logger.error(`[TaskService] Error listing tasks for project ${options.project_id}:`, error);
            throw error;
        }
    }
    /**
     * Retrieves the full details of a single task.
     */
    public async getTaskById(projectId: string, taskId: string): Promise<FullTaskData> {
        logger.info(`[TaskService] Attempting to get task ${taskId} for project ${projectId}`);
        const task = this.taskRepository.findById(projectId, taskId);
        if (!task) {
            logger.warn(`[TaskService] Task ${taskId} not found in project ${projectId}`);
            throw new NotFoundError(`Task with ID ${taskId} not found in project ${projectId}.`);
        }
        try {
            const dependencies = this.taskRepository.findDependencies(taskId);
            const subtasks = this.taskRepository.findSubtasks(taskId);
            const fullTaskData: FullTaskData = {
                ...task,
                dependencies: dependencies,
                subtasks: subtasks,
            };
            logger.info(`[TaskService] Successfully retrieved task ${taskId}`);
            return fullTaskData;
        } catch (error) {
            logger.error(`[TaskService] Error retrieving details for task ${taskId}:`, error);
            throw error;
        }
    }
    /**
     * Sets the status for one or more tasks within a project.
     */
    public async setTaskStatus(projectId: string, taskIds: string[], status: TaskData['status']): Promise<number> {
        logger.info(`[TaskService] Attempting to set status to '${status}' for ${taskIds.length} tasks in project ${projectId}`);
        const projectExists = this.projectRepository.findById(projectId);
        if (!projectExists) {
            logger.warn(`[TaskService] Project not found: ${projectId}`);
            throw new NotFoundError(`Project with ID ${projectId} not found.`);
        }
        const existenceCheck = this.taskRepository.checkTasksExist(projectId, taskIds);
        if (!existenceCheck.allExist) {
            logger.warn(`[TaskService] One or more tasks not found in project ${projectId}:`, existenceCheck.missingIds);
            throw new NotFoundError(`One or more tasks not found in project ${projectId}: ${existenceCheck.missingIds.join(', ')}`);
        }
        try {
            const now = new Date().toISOString();
            const updatedCount = this.taskRepository.updateStatus(projectId, taskIds, status, now);
            if (updatedCount !== taskIds.length) {
                logger.warn(`[TaskService] Expected to update ${taskIds.length} tasks, but ${updatedCount} were affected.`);
            }
            logger.info(`[TaskService] Successfully updated status for ${updatedCount} tasks in project ${projectId}`);
            return updatedCount;
        } catch (error) {
            logger.error(`[TaskService] Error setting status for tasks in project ${projectId}:`, error);
            throw error;
        }
}
    /**
     * Expands a parent task by adding new subtasks.
     * Optionally deletes existing subtasks first if 'force' is true.
     * Uses a transaction to ensure atomicity.
     * @param input - Details including parent task ID, project ID, subtask descriptions, and force flag.
     * @returns The updated parent task details (including new subtasks).
     * @throws {NotFoundError} If the project or parent task is not found.
     * @throws {ConflictError} If subtasks exist and force is false.
     * @throws {Error} If the database operation fails.
     */
    public async expandTask(input: ExpandTaskInput): Promise<FullTaskData> {
        const { project_id, task_id: parentTaskId, subtask_descriptions, force = false } = input;
        logger.info(`[TaskService] Attempting to expand task ${parentTaskId} in project ${project_id} with ${subtask_descriptions.length} subtasks (force=${force})`);
        // Use a transaction for the entire operation
        const expandTransaction = this.db.transaction(() => {
            // 1. Validate Parent Task Existence (within the transaction)
            const parentTask = this.taskRepository.findById(project_id, parentTaskId);
            if (!parentTask) {
                logger.warn(`[TaskService] Parent task ${parentTaskId} not found in project ${project_id}`);
                throw new NotFoundError(`Parent task with ID ${parentTaskId} not found in project ${project_id}.`);
            }
            // 2. Check for existing subtasks
            const existingSubtasks = this.taskRepository.findSubtasks(parentTaskId);
            // 3. Handle existing subtasks based on 'force' flag
            if (existingSubtasks.length > 0) {
                if (!force) {
                    logger.warn(`[TaskService] Conflict: Task ${parentTaskId} already has subtasks and force=false.`);
                    throw new ConflictError(`Task ${parentTaskId} already has subtasks. Use force=true to replace them.`);
                } else {
                    logger.info(`[TaskService] Force=true: Deleting ${existingSubtasks.length} existing subtasks for parent ${parentTaskId}.`);
                    this.taskRepository.deleteSubtasks(parentTaskId);
                    // Note: Dependencies of deleted subtasks are implicitly handled by ON DELETE CASCADE in schema
                }
            }
            // 4. Create new subtasks
            const now = new Date().toISOString();
            const createdSubtasks: TaskData[] = [];
            for (const description of subtask_descriptions) {
                const subtaskId = uuidv4();
                const newSubtaskData: TaskData = {
                    task_id: subtaskId,
                    project_id: project_id,
                    parent_task_id: parentTaskId,
                    description: description, // Assuming length validation done by Zod
                    status: 'todo', // Default status
                    priority: 'medium', // Default priority
                    created_at: now,
                    updated_at: now,
                };
                // Use the repository's create method (which handles its own transaction part for task+deps, but is fine here)
                // We pass an empty array for dependencies as expandTask doesn't set them for new subtasks
                this.taskRepository.create(newSubtaskData, []);
                createdSubtasks.push(newSubtaskData);
            }
            // 5. Fetch updated parent task details (including new subtasks and existing dependencies)
            // We re-fetch to get the consistent state after the transaction commits.
            // Note: This requires the transaction function to return the necessary data.
            // Alternatively, construct the FullTaskData manually here. Let's construct manually.
            const dependencies = this.taskRepository.findDependencies(parentTaskId); // Fetch parent's dependencies
            const finalParentData: FullTaskData = {
                ...parentTask, // Use data fetched at the start of transaction
                updated_at: now, // Update timestamp conceptually (though not saved unless status changes)
                dependencies: dependencies,
                subtasks: createdSubtasks, // Return the newly created subtasks
            };
            return finalParentData;
        });
        try {
            // Execute the transaction
            const result = expandTransaction();
            logger.info(`[TaskService] Successfully expanded task ${parentTaskId} with ${subtask_descriptions.length} new subtasks.`);
            return result;
        } catch (error) {
            logger.error(`[TaskService] Error expanding task ${parentTaskId}:`, error);
            // Re-throw specific errors or generic internal error
            if (error instanceof NotFoundError || error instanceof ConflictError) {
                throw error;
            }
            throw new Error(`Failed to expand task: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Finds the next available task based on readiness (status 'todo', dependencies 'done')
     * and prioritization (priority, creation date).
     * @param projectId - The project ID.
     * @returns The full details of the next task, or null if no task is ready.
     * @throws {NotFoundError} If the project is not found.
     * @throws {Error} If the database operation fails.
     */
    public async getNextTask(projectId: string): Promise<FullTaskData | null> {
        logger.info(`[TaskService] Attempting to get next task for project ${projectId}`);
        // 1. Validate Project Existence
        const projectExists = this.projectRepository.findById(projectId);
        if (!projectExists) {
            logger.warn(`[TaskService] Project not found: ${projectId}`);
            throw new NotFoundError(`Project with ID ${projectId} not found.`);
        }
        // 2. Find ready tasks using the repository method
        try {
            const readyTasks = this.taskRepository.findReadyTasks(projectId);
            if (readyTasks.length === 0) {
                logger.info(`[TaskService] No ready tasks found for project ${projectId}`);
                return null; // No task is ready
            }
            // 3. The first task in the list is the highest priority one due to repo ordering
            const nextTask = readyTasks[0];
            logger.info(`[TaskService] Next task identified: ${nextTask.task_id}`);
            // 4. Fetch full details (dependencies, subtasks) for the selected task
            // We could potentially optimize this if findReadyTasks returned more details,
            // but for separation of concerns, we call getTaskById logic (or similar).
            // Re-using getTaskById logic:
            return await this.getTaskById(projectId, nextTask.task_id);
        } catch (error) {
            logger.error(`[TaskService] Error getting next task for project ${projectId}:`, error);
            throw error; // Re-throw repository or other errors
        }
    }
    /**
     * Updates specific fields of an existing task.
     * @param input - Contains project ID, task ID, and optional fields to update.
     * @returns The full details of the updated task.
     * @throws {ValidationError} If no update fields are provided or if dependencies are invalid.
     * @throws {NotFoundError} If the project, task, or any specified dependency task is not found.
     * @throws {Error} If the database operation fails.
     */
    public async updateTask(input: {
        project_id: string;
        task_id: string;
        description?: string;
        priority?: TaskData['priority'];
        dependencies?: string[];
    }): Promise<FullTaskData> {
        const { project_id, task_id } = input;
        logger.info(`[TaskService] Attempting to update task ${task_id} in project ${project_id}`);
        // 1. Validate that at least one field is being updated
        if (input.description === undefined && input.priority === undefined && input.dependencies === undefined) {
            throw new ValidationError("At least one field (description, priority, or dependencies) must be provided for update.");
        }
        // 2. Validate Project Existence (using repo method)
        const projectExists = this.projectRepository.findById(project_id);
        if (!projectExists) {
            logger.warn(`[TaskService] Project not found: ${project_id}`);
            throw new NotFoundError(`Project with ID ${project_id} not found.`);
        }
        // 3. Validate Task Existence (using repo method - findById also implicitly checks project scope)
        // We need the task data anyway if dependencies are involved, so fetch it now.
        const existingTask = this.taskRepository.findById(project_id, task_id);
        if (!existingTask) {
            logger.warn(`[TaskService] Task ${task_id} not found in project ${project_id}`);
            throw new NotFoundError(`Task with ID ${task_id} not found in project ${project_id}.`);
        }
        // 4. Validate Dependency Existence if provided
        if (input.dependencies !== undefined) {
            if (input.dependencies.length > 0) {
                const depCheck = this.taskRepository.checkTasksExist(project_id, input.dependencies);
                if (!depCheck.allExist) {
                    logger.warn(`[TaskService] Invalid dependencies provided for task ${task_id}:`, depCheck.missingIds);
                    throw new ValidationError(`One or more dependency tasks not found in project ${project_id}: ${depCheck.missingIds.join(', ')}`);
                }
                // Also check for self-dependency
                if (input.dependencies.includes(task_id)) {
                     throw new ValidationError(`Task ${task_id} cannot depend on itself.`);
                }
            }
             // If input.dependencies is an empty array, it means "remove all dependencies"
        }
        // 5. Prepare payload for repository
        const updatePayload: { description?: string; priority?: TaskData['priority']; dependencies?: string[] } = {};
        if (input.description !== undefined) updatePayload.description = input.description;
        if (input.priority !== undefined) updatePayload.priority = input.priority;
        if (input.dependencies !== undefined) updatePayload.dependencies = input.dependencies;
        // 6. Call Repository update method
        try {
            const now = new Date().toISOString();
            // The repo method handles the transaction for task update + dependency replacement
            const updatedTaskData = this.taskRepository.updateTask(project_id, task_id, updatePayload, now);
            // 7. Fetch full details (including potentially updated dependencies and existing subtasks)
            // Re-use logic similar to getTaskById
            const finalDependencies = this.taskRepository.findDependencies(task_id);
            const finalSubtasks = this.taskRepository.findSubtasks(task_id);
            const fullUpdatedTask: FullTaskData = {
                ...updatedTaskData, // Use the data returned by the update method
                dependencies: finalDependencies,
                subtasks: finalSubtasks,
            };
            logger.info(`[TaskService] Successfully updated task ${task_id} in project ${project_id}`);
            return fullUpdatedTask;
        } catch (error) {
            logger.error(`[TaskService] Error updating task ${task_id} in project ${project_id}:`, error);
            // Re-throw specific errors if needed, otherwise let the generic error propagate
             if (error instanceof Error && error.message.includes('not found')) {
                 // Map repo's generic error for not found back to specific NotFoundError
                 throw new NotFoundError(error.message);
             }
            throw error; // Re-throw other errors (like DB constraint errors or unexpected ones)
        }
    }
    /**
     * Deletes one or more tasks within a project.
     * @param projectId - The project ID.
     * @param taskIds - An array of task IDs to delete.
     * @returns The number of tasks successfully deleted.
     * @throws {NotFoundError} If the project or any of the specified tasks are not found.
     * @throws {Error} If the database operation fails.
     */
    public async deleteTasks(projectId: string, taskIds: string[]): Promise<number> {
        logger.info(`[TaskService] Attempting to delete ${taskIds.length} tasks from project ${projectId}`);
        // 1. Validate Project Existence
        const projectExists = this.projectRepository.findById(projectId);
        if (!projectExists) {
            logger.warn(`[TaskService] Project not found: ${projectId}`);
            throw new NotFoundError(`Project with ID ${projectId} not found.`);
        }
        // 2. Validate Task Existence *before* attempting delete
        // This ensures we report an accurate count and catch non-existent IDs early.
        const existenceCheck = this.taskRepository.checkTasksExist(projectId, taskIds);
        if (!existenceCheck.allExist) {
            logger.warn(`[TaskService] Cannot delete: One or more tasks not found in project ${projectId}:`, existenceCheck.missingIds);
            // Throw NotFoundError here, as InvalidParams might be confusing if some IDs were valid
            throw new NotFoundError(`One or more tasks to delete not found in project ${projectId}: ${existenceCheck.missingIds.join(', ')}`);
        }
        // 3. Call Repository delete method
        try {
            // The repository method handles the actual DELETE operation
            const deletedCount = this.taskRepository.deleteTasks(projectId, taskIds);
            // Double-check count (optional, but good sanity check)
            if (deletedCount !== taskIds.length) {
                logger.warn(`[TaskService] Expected to delete ${taskIds.length} tasks, but repository reported ${deletedCount} deletions.`);
                // This might indicate a race condition or unexpected DB behavior, though unlikely with cascade.
                // For V1, we'll trust the repo count but log the warning.
            }
            logger.info(`[TaskService] Successfully deleted ${deletedCount} tasks from project ${projectId}`);
            return deletedCount;
        } catch (error) {
            logger.error(`[TaskService] Error deleting tasks from project ${projectId}:`, error);
            throw error; // Re-throw database or other errors
        }
    }
    // --- Add other task service methods later ---
}