ProjectService.ts•12.2 kB
import { v4 as uuidv4 } from 'uuid';
import { Database as Db } from 'better-sqlite3'; // Import Db type
import { ProjectRepository, ProjectData } from '../repositories/ProjectRepository.js';
import { TaskRepository, TaskData, DependencyData } from '../repositories/TaskRepository.js';
import { logger } from '../utils/logger.js';
import { NotFoundError, ValidationError, ConflictError } from '../utils/errors.js'; // Import errors
// Define structure for the export/import JSON
interface ExportTask extends TaskData {
    dependencies: string[]; // List of task IDs this task depends on
    subtasks: ExportTask[]; // Nested subtasks
}
interface ExportData {
    project_metadata: ProjectData;
    tasks: ExportTask[]; // Root tasks
}
export class ProjectService {
    private projectRepository: ProjectRepository;
    private taskRepository: TaskRepository;
    private db: Db; // Add db instance
    constructor(
        db: Db, // Inject Db instance
        projectRepository: ProjectRepository,
        taskRepository: TaskRepository
    ) {
        this.db = db; // Store db instance
        this.projectRepository = projectRepository;
        this.taskRepository = taskRepository;
    }
    /**
     * Creates a new project.
     */
    public async createProject(projectName?: string): Promise<ProjectData> {
        const projectId = uuidv4();
        const now = new Date().toISOString();
        const finalProjectName = projectName?.trim() || `New Project ${now}`;
        const newProject: ProjectData = {
            project_id: projectId,
            name: finalProjectName,
            created_at: now,
        };
        logger.info(`[ProjectService] Attempting to create project: ${projectId} with name "${finalProjectName}"`);
        try {
            this.projectRepository.create(newProject);
            logger.info(`[ProjectService] Successfully created project: ${projectId}`);
            return newProject;
        } catch (error) {
            logger.error(`[ProjectService] Error creating project ${projectId}:`, error);
            throw error;
        }
    }
    /**
     * Retrieves a project by its ID.
     */
    public async getProjectById(projectId: string): Promise<ProjectData | undefined> {
        logger.info(`[ProjectService] Attempting to find project: ${projectId}`);
        try {
            const project = this.projectRepository.findById(projectId);
            if (project) {
                logger.info(`[ProjectService] Found project: ${projectId}`);
            } else {
                logger.warn(`[ProjectService] Project not found: ${projectId}`);
            }
            return project;
        } catch (error) {
            logger.error(`[ProjectService] Error finding project ${projectId}:`, error);
            throw error;
        }
    }
    /**
     * Exports all data for a given project as a JSON string.
     */
    public async exportProject(projectId: string): Promise<string> {
        logger.info(`[ProjectService] Attempting to export project: ${projectId}`);
        const projectMetadata = this.projectRepository.findById(projectId);
        if (!projectMetadata) {
            logger.warn(`[ProjectService] Project not found for export: ${projectId}`);
            throw new NotFoundError(`Project with ID ${projectId} not found.`);
        }
        try {
            const allTasks = this.taskRepository.findAllTasksForProject(projectId);
            const allDependencies = this.taskRepository.findAllDependenciesForProject(projectId);
            const taskMap: Map<string, ExportTask> = new Map();
            const rootTasks: ExportTask[] = [];
            const dependencyMap: Map<string, string[]> = new Map();
            for (const dep of allDependencies) {
                if (!dependencyMap.has(dep.task_id)) {
                    dependencyMap.set(dep.task_id, []);
                }
                dependencyMap.get(dep.task_id)!.push(dep.depends_on_task_id);
            }
            for (const task of allTasks) {
                taskMap.set(task.task_id, {
                    ...task,
                    dependencies: dependencyMap.get(task.task_id) || [],
                    subtasks: [],
                });
            }
            for (const task of allTasks) {
                const exportTask = taskMap.get(task.task_id)!;
                if (task.parent_task_id && taskMap.has(task.parent_task_id)) {
                    const parent = taskMap.get(task.parent_task_id)!;
                    if (!parent.subtasks) parent.subtasks = [];
                    parent.subtasks.push(exportTask);
                } else if (!task.parent_task_id) {
                    rootTasks.push(exportTask);
                }
            }
            const exportData: ExportData = {
                project_metadata: projectMetadata,
                tasks: rootTasks,
            };
            const jsonString = JSON.stringify(exportData, null, 2);
            logger.info(`[ProjectService] Successfully prepared export data for project ${projectId}`);
            return jsonString;
        } catch (error) {
            logger.error(`[ProjectService] Error exporting project ${projectId}:`, error);
            throw error;
        }
    }
    /**
     * Imports project data from a JSON string, creating a new project.
     */
    public async importProject(projectDataString: string, newProjectName?: string): Promise<{ project_id: string }> {
        logger.info(`[ProjectService] Attempting to import project...`);
        let importData: ExportData;
        try {
            if (projectDataString.length > 10 * 1024 * 1024) { // Example 10MB limit
                throw new ValidationError('Input data exceeds size limit (e.g., 10MB).');
            }
            importData = JSON.parse(projectDataString);
            // TODO: Implement rigorous schema validation (Zod?)
            if (!importData || !importData.project_metadata || !Array.isArray(importData.tasks)) {
                throw new ValidationError('Invalid import data structure: Missing required fields.');
            }
            logger.debug(`[ProjectService] Successfully parsed import data.`);
        } catch (error) {
            logger.error('[ProjectService] Failed to parse or validate import JSON:', error);
            if (error instanceof SyntaxError) {
                throw new ValidationError(`Invalid JSON format: ${error.message}`);
            }
            throw new ValidationError(`Invalid import data: ${error instanceof Error ? error.message : 'Unknown validation error'}`);
        }
        const importTransaction = this.db.transaction(() => {
            const newProjectId = uuidv4();
            const now = new Date().toISOString();
            const finalProjectName = newProjectName?.trim() || `${importData.project_metadata.name} (Imported ${now})`;
            const newProject: ProjectData = {
                project_id: newProjectId,
                name: finalProjectName.substring(0, 255),
                created_at: now,
            };
            this.projectRepository.create(newProject);
            logger.info(`[ProjectService] Created new project ${newProjectId} for import.`);
            const idMap = new Map<string, string>();
            const processTask = (task: ExportTask, parentDbId: string | null) => {
                const newTaskId = uuidv4();
                idMap.set(task.task_id, newTaskId);
                const newTaskData: TaskData = {
                    task_id: newTaskId,
                    project_id: newProjectId,
                    parent_task_id: parentDbId,
                    description: task.description,
                    status: task.status,
                    priority: task.priority,
                    created_at: task.created_at,
                    updated_at: task.updated_at,
                };
                this.taskRepository.create(newTaskData, []); // Create task first
                if (task.subtasks && task.subtasks.length > 0) {
                    task.subtasks.forEach(subtask => processTask(subtask, newTaskId));
                }
            };
            importData.tasks.forEach(rootTask => processTask(rootTask, null));
            logger.info(`[ProjectService] Processed ${idMap.size} tasks for import.`);
            const insertDependencyStmt = this.db.prepare(`
                INSERT INTO task_dependencies (task_id, depends_on_task_id)
                VALUES (?, ?) ON CONFLICT DO NOTHING
            `);
            let depCount = 0;
            const processDeps = (task: ExportTask) => {
                const newTaskId = idMap.get(task.task_id);
                if (newTaskId && task.dependencies && task.dependencies.length > 0) {
                    for (const oldDepId of task.dependencies) {
                        const newDepId = idMap.get(oldDepId);
                        if (newDepId) {
                            insertDependencyStmt.run(newTaskId, newDepId);
                            depCount++;
                        } else {
                            logger.warn(`[ProjectService] Dependency task ID ${oldDepId} not found in import map for task ${task.task_id}. Skipping dependency.`);
                        }
                    }
                }
                if (task.subtasks && task.subtasks.length > 0) {
                    task.subtasks.forEach(processDeps);
                }
            };
            importData.tasks.forEach(processDeps);
            logger.info(`[ProjectService] Processed ${depCount} dependencies for import.`);
            return { project_id: newProjectId };
        });
        try {
            const result = importTransaction();
            logger.info(`[ProjectService] Successfully imported project. New project ID: ${result.project_id}`);
            return result;
        } catch (error) {
            logger.error(`[ProjectService] Error during import transaction:`, error);
            if (error instanceof NotFoundError || error instanceof ValidationError || error instanceof ConflictError) {
                throw error;
            }
            throw new Error(`Failed to import project: ${error instanceof Error ? error.message : 'Unknown database error'}`);
        }
    }
    /**
     * Deletes a project and all its associated data (tasks, dependencies).
     * @param projectId - The ID of the project to delete.
     * @returns A boolean indicating success (true if deleted, false if not found initially).
     * @throws {NotFoundError} If the project is not found.
     * @throws {Error} If the database operation fails.
     */
    public async deleteProject(projectId: string): Promise<boolean> {
        logger.info(`[ProjectService] Attempting to delete project: ${projectId}`);
        // 1. Validate Project Existence *before* attempting delete
        const projectExists = this.projectRepository.findById(projectId);
        if (!projectExists) {
            logger.warn(`[ProjectService] Project not found for deletion: ${projectId}`);
            throw new NotFoundError(`Project with ID ${projectId} not found.`);
        }
        // 2. Call Repository delete method
        try {
            // The repository method handles the actual DELETE operation on the projects table.
            // Cascade delete defined in the schema handles tasks and dependencies.
            const deletedCount = this.projectRepository.deleteProject(projectId);
            if (deletedCount !== 1) {
                // This shouldn't happen if findById succeeded, but log a warning if it does.
                logger.warn(`[ProjectService] Expected to delete 1 project, but repository reported ${deletedCount} deletions for project ${projectId}.`);
                // Still return true as the project is gone, but log indicates potential issue.
            }
            logger.info(`[ProjectService] Successfully deleted project ${projectId} and associated data.`);
            return true; // Indicate success
        } catch (error) {
            logger.error(`[ProjectService] Error deleting project ${projectId}:`, error);
            throw error; // Re-throw database or other errors
        }
    }
}