Files
claude-task-master/packages/tm-core/src/entities/task.entity.ts
2025-10-03 18:47:05 +02:00

276 lines
6.3 KiB
TypeScript

/**
* @fileoverview Task entity with business rules and domain logic
*/
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
import type {
Subtask,
Task,
TaskPriority,
TaskStatus
} from '../types/index.js';
/**
* Task entity representing a task with business logic
* Encapsulates validation and state management rules
*/
export class TaskEntity implements Task {
readonly id: string;
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
dependencies: string[];
details: string;
testStrategy: string;
subtasks: Subtask[];
// Optional properties
createdAt?: string;
updatedAt?: string;
effort?: number;
actualEffort?: number;
tags?: string[];
assignee?: string;
complexity?: Task['complexity'];
recommendedSubtasks?: number;
expansionPrompt?: string;
complexityReasoning?: string;
constructor(data: Task | (Omit<Task, 'id'> & { id: number | string })) {
this.validate(data);
// Always convert ID to string
this.id = String(data.id);
this.title = data.title;
this.description = data.description;
this.status = data.status;
this.priority = data.priority;
// Ensure dependency IDs are also strings
this.dependencies = (data.dependencies || []).map((dep) => String(dep));
this.details = data.details;
this.testStrategy = data.testStrategy;
// Normalize subtask IDs to strings
this.subtasks = (data.subtasks || []).map((subtask) => ({
...subtask,
id: Number(subtask.id), // Keep subtask IDs as numbers per interface
parentId: String(subtask.parentId)
}));
// Optional properties
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this.effort = data.effort;
this.actualEffort = data.actualEffort;
this.tags = data.tags;
this.assignee = data.assignee;
this.complexity = data.complexity;
this.recommendedSubtasks = data.recommendedSubtasks;
this.expansionPrompt = data.expansionPrompt;
this.complexityReasoning = data.complexityReasoning;
}
/**
* Validate task data
*/
private validate(
data: Partial<Task> | Partial<Omit<Task, 'id'> & { id: number | string }>
): void {
if (
data.id === undefined ||
data.id === null ||
(typeof data.id !== 'string' && typeof data.id !== 'number')
) {
throw new TaskMasterError(
'Task ID is required and must be a string or number',
ERROR_CODES.VALIDATION_ERROR
);
}
if (!data.title || data.title.trim().length === 0) {
throw new TaskMasterError(
'Task title is required',
ERROR_CODES.VALIDATION_ERROR
);
}
if (!data.description || data.description.trim().length === 0) {
throw new TaskMasterError(
'Task description is required',
ERROR_CODES.VALIDATION_ERROR
);
}
if (!this.isValidStatus(data.status)) {
throw new TaskMasterError(
`Invalid task status: ${data.status}`,
ERROR_CODES.VALIDATION_ERROR
);
}
if (!this.isValidPriority(data.priority)) {
throw new TaskMasterError(
`Invalid task priority: ${data.priority}`,
ERROR_CODES.VALIDATION_ERROR
);
}
}
/**
* Check if status is valid
*/
private isValidStatus(status: any): status is TaskStatus {
return [
'pending',
'in-progress',
'done',
'deferred',
'cancelled',
'blocked',
'review'
].includes(status);
}
/**
* Check if priority is valid
*/
private isValidPriority(priority: any): priority is TaskPriority {
return ['low', 'medium', 'high', 'critical'].includes(priority);
}
/**
* Check if task can be marked as complete
*/
canComplete(): boolean {
// Cannot complete if status is already done or cancelled
if (this.status === 'done' || this.status === 'cancelled') {
return false;
}
// Cannot complete if blocked
if (this.status === 'blocked') {
return false;
}
// Check if all subtasks are complete
const allSubtasksComplete = this.subtasks.every(
(subtask) => subtask.status === 'done' || subtask.status === 'cancelled'
);
return allSubtasksComplete;
}
/**
* Mark task as complete
*/
markAsComplete(): void {
if (!this.canComplete()) {
throw new TaskMasterError(
'Task cannot be marked as complete',
ERROR_CODES.TASK_STATUS_ERROR,
{
taskId: this.id,
currentStatus: this.status,
hasIncompleteSubtasks: this.subtasks.some(
(s) => s.status !== 'done' && s.status !== 'cancelled'
)
}
);
}
this.status = 'done';
this.updatedAt = new Date().toISOString();
}
/**
* Check if task has dependencies
*/
hasDependencies(): boolean {
return this.dependencies.length > 0;
}
/**
* Check if task has subtasks
*/
hasSubtasks(): boolean {
return this.subtasks.length > 0;
}
/**
* Add a subtask
*/
addSubtask(subtask: Omit<Subtask, 'id' | 'parentId'>): void {
const nextId = this.subtasks.length + 1;
this.subtasks.push({
...subtask,
id: nextId,
parentId: this.id
});
this.updatedAt = new Date().toISOString();
}
/**
* Update task status
*/
updateStatus(newStatus: TaskStatus): void {
if (!this.isValidStatus(newStatus)) {
throw new TaskMasterError(
`Invalid status: ${newStatus}`,
ERROR_CODES.VALIDATION_ERROR
);
}
// Business rule: Cannot move from done to pending
if (this.status === 'done' && newStatus === 'pending') {
throw new TaskMasterError(
'Cannot move completed task back to pending',
ERROR_CODES.TASK_STATUS_ERROR
);
}
this.status = newStatus;
this.updatedAt = new Date().toISOString();
}
/**
* Convert entity to plain object
*/
toJSON(): Task {
return {
id: this.id,
title: this.title,
description: this.description,
status: this.status,
priority: this.priority,
dependencies: this.dependencies,
details: this.details,
testStrategy: this.testStrategy,
subtasks: this.subtasks,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
effort: this.effort,
actualEffort: this.actualEffort,
tags: this.tags,
assignee: this.assignee,
complexity: this.complexity,
recommendedSubtasks: this.recommendedSubtasks,
expansionPrompt: this.expansionPrompt,
complexityReasoning: this.complexityReasoning
};
}
/**
* Create TaskEntity from plain object
*/
static fromObject(data: Task): TaskEntity {
return new TaskEntity(data);
}
/**
* Create multiple TaskEntities from array
*/
static fromArray(data: Task[]): TaskEntity[] {
return data.map((task) => new TaskEntity(task));
}
}