import { Database as Db, Statement } from 'better-sqlite3';
import { logger } from '../utils/logger.js';
// Define the structure for task data in the database
// Aligning with schema.sql and feature specs
export interface TaskData {
task_id: string; // UUID
project_id: string; // UUID
parent_task_id?: string | null; // UUID or null
description: string;
status: 'todo' | 'in-progress' | 'review' | 'done';
priority: 'high' | 'medium' | 'low';
created_at: string; // ISO8601
updated_at: string; // ISO8601
}
// Define the structure for dependency data
export interface DependencyData {
task_id: string;
depends_on_task_id: string;
}
export class TaskRepository {
private db: Db;
private insertTaskStmt: Statement | null = null;
private insertDependencyStmt: Statement | null = null;
constructor(db: Db) {
this.db = db;
// Prepare statements for efficiency
this.prepareStatements();
}
private prepareStatements(): void {
try {
this.insertTaskStmt = this.db.prepare(`
INSERT INTO tasks (
task_id, project_id, parent_task_id, description,
status, priority, created_at, updated_at
) VALUES (
@task_id, @project_id, @parent_task_id, @description,
@status, @priority, @created_at, @updated_at
)
`);
this.insertDependencyStmt = this.db.prepare(`
INSERT INTO task_dependencies (task_id, depends_on_task_id)
VALUES (@task_id, @depends_on_task_id)
ON CONFLICT(task_id, depends_on_task_id) DO NOTHING -- Ignore if dependency already exists
`);
} catch (error) {
logger.error('[TaskRepository] Failed to prepare statements:', error);
// Handle error appropriately, maybe re-throw or set a flag
throw error;
}
}
/**
* Creates a new task and optionally its dependencies in the database.
* Uses a transaction to ensure atomicity.
* @param task - The core task data to insert.
* @param dependencies - An array of dependency task IDs for this task.
* @throws {Error} If the database operation fails.
*/
public create(task: TaskData, dependencies: string[] = []): void {
if (!this.insertTaskStmt || !this.insertDependencyStmt) {
logger.error('[TaskRepository] Statements not prepared. Cannot create task.');
throw new Error('TaskRepository statements not initialized.');
}
// Use a transaction for atomicity
const transaction = this.db.transaction((taskData: TaskData, deps: string[]) => {
// Insert the main task
const taskInfo = this.insertTaskStmt!.run(taskData);
if (taskInfo.changes !== 1) {
throw new Error(`Failed to insert task ${taskData.task_id}. Changes: ${taskInfo.changes}`);
}
// Insert dependencies
for (const depId of deps) {
const depData: DependencyData = {
task_id: taskData.task_id,
depends_on_task_id: depId,
};
const depInfo = this.insertDependencyStmt!.run(depData);
// We don't strictly need to check changes here due to ON CONFLICT DO NOTHING
}
return taskInfo.changes; // Indicate success
});
try {
transaction(task, dependencies);
logger.info(`[TaskRepository] Created task ${task.task_id} with ${dependencies.length} dependencies.`);
} catch (error) {
logger.error(`[TaskRepository] Failed to create task ${task.task_id} transaction:`, error);
throw error; // Re-throw to be handled by the service layer
}
}
/**
* Finds tasks by project ID, optionally filtering by status.
* Does not handle subtask nesting directly in this query for V1 simplicity.
* @param projectId - The ID of the project.
* @param statusFilter - Optional status to filter by.
* @returns An array of matching task data.
*/
public findByProjectId(projectId: string, statusFilter?: TaskData['status']): TaskData[] {
let sql = `
SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at
FROM tasks
WHERE project_id = ?
`;
const params: (string | null)[] = [projectId];
if (statusFilter) {
sql += ` AND status = ?`;
params.push(statusFilter);
}
// For simplicity in V1, we only fetch top-level tasks or all tasks depending on include_subtasks strategy in service
// If we only wanted top-level: sql += ` AND parent_task_id IS NULL`;
// If fetching all and structuring in service, this query is fine.
sql += ` ORDER BY created_at ASC`; // Default sort order
try {
const stmt = this.db.prepare(sql);
const tasks = stmt.all(...params) as TaskData[];
logger.debug(`[TaskRepository] Found ${tasks.length} tasks for project ${projectId} with status filter '${statusFilter || 'none'}'`);
return tasks;
} catch (error) {
logger.error(`[TaskRepository] Failed to find tasks for project ${projectId}:`, error);
throw error; // Re-throw
}
}
/**
* Finds a single task by its ID and project ID.
* @param projectId - The project ID.
* @param taskId - The task ID.
* @returns The task data if found, otherwise undefined.
*/
public findById(projectId: string, taskId: string): TaskData | undefined {
const sql = `
SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at
FROM tasks
WHERE project_id = ? AND task_id = ?
`;
try {
const stmt = this.db.prepare(sql);
const task = stmt.get(projectId, taskId) as TaskData | undefined;
logger.debug(`[TaskRepository] Found task ${taskId} in project ${projectId}: ${!!task}`);
return task;
} catch (error) {
logger.error(`[TaskRepository] Failed to find task ${taskId} in project ${projectId}:`, error);
throw error;
}
}
/**
* Finds the direct subtasks for a given parent task ID.
* @param parentTaskId - The ID of the parent task.
* @returns An array of direct subtask data.
*/
public findSubtasks(parentTaskId: string): TaskData[] {
const sql = `
SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at
FROM tasks
WHERE parent_task_id = ?
ORDER BY created_at ASC
`;
try {
const stmt = this.db.prepare(sql);
const subtasks = stmt.all(parentTaskId) as TaskData[];
logger.debug(`[TaskRepository] Found ${subtasks.length} subtasks for parent ${parentTaskId}`);
return subtasks;
} catch (error) {
logger.error(`[TaskRepository] Failed to find subtasks for parent ${parentTaskId}:`, error);
throw error;
}
}
/**
* Finds the IDs of tasks that the given task depends on.
* @param taskId - The ID of the task whose dependencies are needed.
* @returns An array of task IDs that this task depends on.
*/
public findDependencies(taskId: string): string[] {
const sql = `SELECT depends_on_task_id FROM task_dependencies WHERE task_id = ?`;
try {
const stmt = this.db.prepare(sql);
// Ensure result is always an array of strings
const results = stmt.all(taskId) as { depends_on_task_id: string }[];
const dependencyIds = results.map(row => row.depends_on_task_id);
logger.debug(`[TaskRepository] Found ${dependencyIds.length} dependencies for task ${taskId}`);
return dependencyIds;
} catch (error) {
logger.error(`[TaskRepository] Failed to find dependencies for task ${taskId}:`, error);
throw error;
}
}
/**
* Updates the status and updated_at timestamp for a list of tasks within a project.
* Assumes task existence has already been verified.
* @param projectId - The project ID.
* @param taskIds - An array of task IDs to update.
* @param status - The new status to set.
* @param timestamp - The ISO8601 timestamp for updated_at.
* @returns The number of rows affected by the update.
* @throws {Error} If the database operation fails.
*/
public updateStatus(projectId: string, taskIds: string[], status: TaskData['status'], timestamp: string): number {
if (taskIds.length === 0) {
return 0;
}
// Create placeholders for the IN clause
const placeholders = taskIds.map(() => '?').join(',');
const sql = `
UPDATE tasks
SET status = ?, updated_at = ?
WHERE project_id = ? AND task_id IN (${placeholders})
`;
const params = [status, timestamp, projectId, ...taskIds];
try {
const stmt = this.db.prepare(sql);
const info = stmt.run(...params);
logger.info(`[TaskRepository] Updated status for ${info.changes} tasks in project ${projectId} to ${status}.`);
return info.changes;
} catch (error) {
logger.error(`[TaskRepository] Failed to update status for tasks in project ${projectId}:`, error);
throw error;
}
}
/**
* Checks if all provided task IDs exist within the specified project.
* @param projectId - The project ID.
* @param taskIds - An array of task IDs to check.
* @returns An object indicating if all exist and a list of missing IDs if not.
* @throws {Error} If the database operation fails.
*/
public checkTasksExist(projectId: string, taskIds: string[]): { allExist: boolean; missingIds: string[] } {
if (taskIds.length === 0) {
return { allExist: true, missingIds: [] };
}
const placeholders = taskIds.map(() => '?').join(',');
const sql = `
SELECT task_id FROM tasks
WHERE project_id = ? AND task_id IN (${placeholders})
`;
const params = [projectId, ...taskIds];
try {
const stmt = this.db.prepare(sql);
const foundTasks = stmt.all(...params) as { task_id: string }[];
const foundIds = new Set(foundTasks.map(t => t.task_id));
const missingIds = taskIds.filter(id => !foundIds.has(id));
const allExist = missingIds.length === 0;
if (!allExist) {
logger.warn(`[TaskRepository] Missing tasks in project ${projectId}:`, missingIds);
}
return { allExist, missingIds };
} catch (error) {
logger.error(`[TaskRepository] Failed to check task existence in project ${projectId}:`, error);
throw error;
}
}
/**
* Deletes all direct subtasks of a given parent task.
* @param parentTaskId - The ID of the parent task whose subtasks should be deleted.
* @returns The number of subtasks deleted.
* @throws {Error} If the database operation fails.
*/
public deleteSubtasks(parentTaskId: string): number {
const sql = `DELETE FROM tasks WHERE parent_task_id = ?`;
try {
const stmt = this.db.prepare(sql);
const info = stmt.run(parentTaskId);
logger.info(`[TaskRepository] Deleted ${info.changes} subtasks for parent ${parentTaskId}.`);
return info.changes;
} catch (error) {
logger.error(`[TaskRepository] Failed to delete subtasks for parent ${parentTaskId}:`, error);
throw error;
}
}
/**
* Finds tasks that are ready to be worked on (status 'todo' and all dependencies 'done').
* Orders them by priority ('high', 'medium', 'low') then creation date.
* @param projectId - The project ID.
* @returns An array of ready task data, ordered by priority and creation date.
*/
public findReadyTasks(projectId: string): TaskData[] {
// This query finds tasks in the project with status 'todo'
// AND for which no dependency exists OR all existing dependencies have status 'done'.
const sql = `
SELECT t.task_id, t.project_id, t.parent_task_id, t.description, t.status, t.priority, t.created_at, t.updated_at
FROM tasks t
WHERE t.project_id = ? AND t.status = 'todo'
AND NOT EXISTS (
SELECT 1
FROM task_dependencies td
JOIN tasks dep_task ON td.depends_on_task_id = dep_task.task_id
WHERE td.task_id = t.task_id AND dep_task.status != 'done'
)
ORDER BY
CASE t.priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
ELSE 4 -- Should not happen based on CHECK constraint
END ASC,
t.created_at ASC
`;
try {
const stmt = this.db.prepare(sql);
const tasks = stmt.all(projectId) as TaskData[];
logger.debug(`[TaskRepository] Found ${tasks.length} ready tasks for project ${projectId}`);
return tasks;
} catch (error) {
logger.error(`[TaskRepository] Failed to find ready tasks for project ${projectId}:`, error);
throw error;
}
}
/**
* Finds ALL tasks for a given project ID, ordered by creation date.
* @param projectId - The project ID.
* @returns An array of all task data for the project.
*/
public findAllTasksForProject(projectId: string): TaskData[] {
const sql = `
SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at
FROM tasks
WHERE project_id = ?
ORDER BY created_at ASC
`;
try {
const stmt = this.db.prepare(sql);
const tasks = stmt.all(projectId) as TaskData[];
logger.debug(`[TaskRepository] Found all ${tasks.length} tasks for project ${projectId}`);
return tasks;
} catch (error) {
logger.error(`[TaskRepository] Failed to find all tasks for project ${projectId}:`, error);
throw error;
}
}
/**
* Finds ALL dependencies for tasks within a given project ID.
* @param projectId - The project ID.
* @returns An array of all dependency relationships for the project.
*/
public findAllDependenciesForProject(projectId: string): DependencyData[] {
// Select dependencies where the *dependent* task belongs to the project
const sql = `
SELECT td.task_id, td.depends_on_task_id
FROM task_dependencies td
JOIN tasks t ON td.task_id = t.task_id
WHERE t.project_id = ?
`;
try {
const stmt = this.db.prepare(sql);
const dependencies = stmt.all(projectId) as DependencyData[];
logger.debug(`[TaskRepository] Found ${dependencies.length} dependencies for project ${projectId}`);
return dependencies;
} catch (error) {
logger.error(`[TaskRepository] Failed to find all dependencies for project ${projectId}:`, error);
throw error;
}
}
// --- Add other methods later ---
/**
* Updates a task's description, priority, and/or dependencies.
* Handles dependency replacement atomically within a transaction.
* @param projectId - The project ID.
* @param taskId - The task ID to update.
* @param updatePayload - Object containing optional fields to update.
* @param timestamp - The ISO8601 timestamp for updated_at.
* @returns The updated task data.
* @throws {Error} If the task doesn't exist or the database operation fails.
*/
public updateTask(
projectId: string,
taskId: string,
updatePayload: { description?: string; priority?: TaskData['priority']; dependencies?: string[] },
timestamp: string
): TaskData {
const transaction = this.db.transaction(() => {
const setClauses: string[] = [];
const params: (string | null)[] = [];
if (updatePayload.description !== undefined) {
setClauses.push('description = ?');
params.push(updatePayload.description);
}
if (updatePayload.priority !== undefined) {
setClauses.push('priority = ?');
params.push(updatePayload.priority);
}
// Always update the timestamp
setClauses.push('updated_at = ?');
params.push(timestamp);
// If nothing else to update, we still update the timestamp
if (setClauses.length === 1 && updatePayload.dependencies === undefined) {
logger.warn(`[TaskRepository] updateTask called for ${taskId} with no fields to update other than timestamp.`);
// Or potentially throw an error if this shouldn't happen based on service validation
}
// Update the main task table if there are fields to update
let changes = 0;
if (setClauses.length > 0) {
const updateSql = `
UPDATE tasks
SET ${setClauses.join(', ')}
WHERE project_id = ? AND task_id = ?
`;
params.push(projectId, taskId);
const updateStmt = this.db.prepare(updateSql);
const info = updateStmt.run(...params);
changes = info.changes;
if (changes !== 1) {
// Check if the task actually exists before throwing generic error
const exists = this.findById(projectId, taskId);
if (!exists) {
throw new Error(`Task ${taskId} not found in project ${projectId}.`); // Will be caught and mapped later
} else {
throw new Error(`Failed to update task ${taskId}. Expected 1 change, got ${changes}.`);
}
}
logger.debug(`[TaskRepository] Updated task ${taskId} fields.`);
}
// Handle dependencies if provided (replaces existing)
if (updatePayload.dependencies !== undefined) {
if (!this.insertDependencyStmt) {
throw new Error('TaskRepository insertDependencyStmt not initialized.');
}
// 1. Delete existing dependencies for this task
const deleteDepsStmt = this.db.prepare(`DELETE FROM task_dependencies WHERE task_id = ?`);
const deleteInfo = deleteDepsStmt.run(taskId);
logger.debug(`[TaskRepository] Deleted ${deleteInfo.changes} existing dependencies for task ${taskId}.`);
// 2. Insert new dependencies
const newDeps = updatePayload.dependencies;
for (const depId of newDeps) {
const depData: DependencyData = {
task_id: taskId,
depends_on_task_id: depId,
};
// ON CONFLICT DO NOTHING handles duplicates or self-references if schema allows
this.insertDependencyStmt.run(depData);
}
logger.debug(`[TaskRepository] Inserted ${newDeps.length} new dependencies for task ${taskId}.`);
}
// Fetch and return the updated task data
const updatedTask = this.findById(projectId, taskId);
if (!updatedTask) {
// Should not happen if update succeeded, but safety check
throw new Error(`Failed to retrieve updated task ${taskId} after update.`);
}
return updatedTask;
});
try {
const result = transaction();
logger.info(`[TaskRepository] Successfully updated task ${taskId}.`);
return result;
} catch (error) {
logger.error(`[TaskRepository] Failed transaction for updating task ${taskId}:`, error);
throw error; // Re-throw to be handled by the service layer
}
}
/**
* Deletes multiple tasks by their IDs within a specific project.
* Relies on ON DELETE CASCADE for subtasks and dependencies.
* @param projectId - The project ID.
* @param taskIds - An array of task IDs to delete.
* @returns The number of tasks deleted.
* @throws {Error} If the database operation fails.
*/
public deleteTasks(projectId: string, taskIds: string[]): number {
if (taskIds.length === 0) {
return 0;
}
// Create placeholders for the IN clause
const placeholders = taskIds.map(() => '?').join(',');
const sql = `
DELETE FROM tasks
WHERE project_id = ? AND task_id IN (${placeholders})
`;
const params = [projectId, ...taskIds];
try {
const stmt = this.db.prepare(sql);
const info = stmt.run(...params);
logger.info(`[TaskRepository] Deleted ${info.changes} tasks from project ${projectId}.`);
// Note: Cascade deletes for subtasks/dependencies happen automatically via schema.
return info.changes;
} catch (error) {
logger.error(`[TaskRepository] Failed to delete tasks from project ${projectId}:`, error);
throw error;
}
}
// --- Add other methods later ---
// deleteById(taskId: string): void;
}