diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index a49583b5..a3ce900e 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -246,7 +246,7 @@ export class ListTasksCommand extends Command { task.subtasks.forEach((subtask) => { const subIcon = STATUS_ICONS[subtask.status]; console.log( - ` ${chalk.gray(`${task.id}.${subtask.id}`)} ${subIcon} ${chalk.gray(subtask.title)}` + ` ${chalk.gray(String(subtask.id))} ${subIcon} ${chalk.gray(subtask.title)}` ); }); } @@ -297,7 +297,7 @@ export class ListTasksCommand extends Command { nextTask ); - // Task table - no title, just show the table directly + // Task table console.log( ui.createTaskTable(tasks, { showSubtasks: withSubtasks, diff --git a/apps/cli/src/commands/set-status.command.ts b/apps/cli/src/commands/set-status.command.ts index d6ae39c7..960febba 100644 --- a/apps/cli/src/commands/set-status.command.ts +++ b/apps/cli/src/commands/set-status.command.ts @@ -258,9 +258,6 @@ export class SetStatusCommand extends Command { ) ); } - - // Show storage info - console.log(chalk.gray(`\nUsing ${result.storageType} storage`)); } /** diff --git a/apps/cli/src/ui/components/task-detail.component.ts b/apps/cli/src/ui/components/task-detail.component.ts index 646d7616..2f874d2b 100644 --- a/apps/cli/src/ui/components/task-detail.component.ts +++ b/apps/cli/src/ui/components/task-detail.component.ts @@ -192,8 +192,7 @@ export function displaySubtasks( status: any; description?: string; dependencies?: string[]; - }>, - parentId: string | number + }> ): void { const terminalWidth = process.stdout.columns * 0.95 || 100; // Display subtasks header @@ -228,7 +227,7 @@ export function displaySubtasks( }); subtasks.forEach((subtask) => { - const subtaskId = `${parentId}.${subtask.id}`; + const subtaskId = String(subtask.id); // Format dependencies const deps = @@ -329,7 +328,7 @@ export function displayTaskDetails( console.log(chalk.gray(` No subtasks with status '${statusFilter}'`)); } else if (filteredSubtasks.length > 0) { console.log(); // Empty line for spacing - displaySubtasks(filteredSubtasks, task.id); + displaySubtasks(filteredSubtasks); } } diff --git a/apps/cli/src/utils/ui.ts b/apps/cli/src/utils/ui.ts index 60626a8c..533bfb0f 100644 --- a/apps/cli/src/utils/ui.ts +++ b/apps/cli/src/utils/ui.ts @@ -286,12 +286,12 @@ export function createTaskTable( // Adjust column widths to better match the original layout const baseColWidths = showComplexity ? [ - Math.floor(terminalWidth * 0.06), + Math.floor(terminalWidth * 0.1), Math.floor(terminalWidth * 0.4), Math.floor(terminalWidth * 0.15), - Math.floor(terminalWidth * 0.12), + Math.floor(terminalWidth * 0.1), Math.floor(terminalWidth * 0.2), - Math.floor(terminalWidth * 0.12) + Math.floor(terminalWidth * 0.1) ] // ID, Title, Status, Priority, Dependencies, Complexity : [ Math.floor(terminalWidth * 0.08), @@ -377,7 +377,11 @@ export function createTaskTable( } if (showComplexity) { - subRow.push(chalk.gray('--')); + const complexityDisplay = + typeof subtask.complexity === 'number' + ? getComplexityWithColor(subtask.complexity) + : '--'; + subRow.push(chalk.gray(complexityDisplay)); } table.push(subRow); diff --git a/package-lock.json b/package-lock.json index 8c460ec8..51d2fda6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "task-master-ai", - "version": "0.27.3", + "version": "0.28.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "task-master-ai", - "version": "0.27.3", + "version": "0.28.0-rc.1", "license": "MIT WITH Commons-Clause", "workspaces": [ "apps/*", @@ -131,7 +131,7 @@ } }, "apps/extension": { - "version": "0.25.4", + "version": "0.25.5-rc.0", "dependencies": { "task-master-ai": "*" }, @@ -635,7 +635,6 @@ "apps/extension/node_modules/zod": { "version": "3.25.76", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -1830,7 +1829,6 @@ "version": "7.28.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2663,7 +2661,6 @@ "version": "6.3.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -4583,6 +4580,7 @@ "version": "0.23.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -5172,6 +5170,7 @@ "version": "0.23.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -5180,7 +5179,6 @@ "version": "3.25.76", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5471,7 +5469,6 @@ "node_modules/@modelcontextprotocol/sdk/node_modules/zod": { "version": "3.25.76", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5572,7 +5569,6 @@ "node_modules/@opentelemetry/api": { "version": "1.9.0", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -8592,7 +8588,6 @@ "version": "19.1.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -8601,7 +8596,6 @@ "version": "19.1.6", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -9047,7 +9041,6 @@ "node_modules/acorn": { "version": "8.15.0", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9113,7 +9106,6 @@ "node_modules/ai": { "version": "5.0.57", "license": "Apache-2.0", - "peer": true, "dependencies": { "@ai-sdk/gateway": "1.0.30", "@ai-sdk/provider": "2.0.0", @@ -9333,7 +9325,6 @@ "node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10339,7 +10330,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -12203,8 +12193,7 @@ "node_modules/devtools-protocol": { "version": "0.0.1312386", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -12798,7 +12787,6 @@ "version": "0.25.10", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -13111,7 +13099,6 @@ "node_modules/express": { "version": "4.21.2", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -15465,7 +15452,6 @@ "version": "6.3.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -16423,7 +16409,6 @@ "version": "29.7.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -18041,7 +18026,6 @@ "version": "1.4.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.16.0" } @@ -18367,6 +18351,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18591,6 +18576,7 @@ "version": "1.4.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -18721,7 +18707,6 @@ "node_modules/marked": { "version": "15.0.12", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -21444,7 +21429,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -22827,7 +22811,6 @@ "integrity": "sha512-U+NPR0Bkg3wm61dteD2L4nAM1U9dtaqVrpDXwC36IKRHpEO/Ubpid4Nijpa2imPchcVNHfxVFwSSMJdwdGFUbg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.93.0", "@rolldown/pluginutils": "1.0.0-beta.41", @@ -25256,7 +25239,6 @@ "version": "5.9.2", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -25373,7 +25355,6 @@ "version": "11.0.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -25816,7 +25797,6 @@ "version": "5.4.20", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -25929,6 +25909,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=12" } @@ -26512,7 +26493,7 @@ }, "node_modules/yaml": { "version": "1.10.2", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">= 6" @@ -26655,7 +26636,6 @@ "node_modules/zod": { "version": "4.1.11", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -27397,7 +27377,6 @@ "version": "3.2.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/packages/tm-core/src/entities/task.entity.ts b/packages/tm-core/src/entities/task.entity.ts index 32403034..8737615f 100644 --- a/packages/tm-core/src/entities/task.entity.ts +++ b/packages/tm-core/src/entities/task.entity.ts @@ -53,7 +53,7 @@ export class TaskEntity implements Task { // Normalize subtask IDs to strings this.subtasks = (data.subtasks || []).map((subtask) => ({ ...subtask, - id: Number(subtask.id), // Keep subtask IDs as numbers per interface + id: String(subtask.id), parentId: String(subtask.parentId) })); diff --git a/packages/tm-core/src/mappers/TaskMapper.test.ts b/packages/tm-core/src/mappers/TaskMapper.test.ts new file mode 100644 index 00000000..e033197e --- /dev/null +++ b/packages/tm-core/src/mappers/TaskMapper.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi } from 'vitest'; +import { TaskMapper } from './TaskMapper.js'; +import type { Tables } from '../types/database.types.js'; + +type TaskRow = Tables<'tasks'>; + +describe('TaskMapper', () => { + describe('extractMetadataField', () => { + it('should extract string field from metadata', () => { + const taskRow: TaskRow = { + id: '123', + display_id: '1', + title: 'Test Task', + description: 'Test description', + status: 'todo', + priority: 'medium', + parent_task_id: null, + subtask_position: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + metadata: { + details: 'Some details', + testStrategy: 'Test with unit tests' + }, + complexity: null, + assignee_id: null, + estimated_hours: null, + actual_hours: null, + due_date: null, + completed_at: null + }; + + const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map()); + + expect(task.details).toBe('Some details'); + expect(task.testStrategy).toBe('Test with unit tests'); + }); + + it('should use default value when metadata field is missing', () => { + const taskRow: TaskRow = { + id: '123', + display_id: '1', + title: 'Test Task', + description: 'Test description', + status: 'todo', + priority: 'medium', + parent_task_id: null, + subtask_position: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + metadata: {}, + complexity: null, + assignee_id: null, + estimated_hours: null, + actual_hours: null, + due_date: null, + completed_at: null + }; + + const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map()); + + expect(task.details).toBe(''); + expect(task.testStrategy).toBe(''); + }); + + it('should use default value when metadata is null', () => { + const taskRow: TaskRow = { + id: '123', + display_id: '1', + title: 'Test Task', + description: 'Test description', + status: 'todo', + priority: 'medium', + parent_task_id: null, + subtask_position: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + metadata: null, + complexity: null, + assignee_id: null, + estimated_hours: null, + actual_hours: null, + due_date: null, + completed_at: null + }; + + const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map()); + + expect(task.details).toBe(''); + expect(task.testStrategy).toBe(''); + }); + + it('should use default value and warn when metadata field has wrong type', () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const taskRow: TaskRow = { + id: '123', + display_id: '1', + title: 'Test Task', + description: 'Test description', + status: 'todo', + priority: 'medium', + parent_task_id: null, + subtask_position: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + metadata: { + details: 12345, // Wrong type: number instead of string + testStrategy: ['test1', 'test2'] // Wrong type: array instead of string + }, + complexity: null, + assignee_id: null, + estimated_hours: null, + actual_hours: null, + due_date: null, + completed_at: null + }; + + const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map()); + + // Should use empty string defaults when type doesn't match + expect(task.details).toBe(''); + expect(task.testStrategy).toBe(''); + + // Should have logged warnings + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Type mismatch in metadata field "details"') + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Type mismatch in metadata field "testStrategy"' + ) + ); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('mapStatus', () => { + it('should map database status to internal status', () => { + expect(TaskMapper.mapStatus('todo')).toBe('pending'); + expect(TaskMapper.mapStatus('in_progress')).toBe('in-progress'); + expect(TaskMapper.mapStatus('done')).toBe('done'); + }); + }); +}); diff --git a/packages/tm-core/src/mappers/TaskMapper.ts b/packages/tm-core/src/mappers/TaskMapper.ts index 89f5a8eb..8fadf73f 100644 --- a/packages/tm-core/src/mappers/TaskMapper.ts +++ b/packages/tm-core/src/mappers/TaskMapper.ts @@ -2,22 +2,32 @@ import { Task, Subtask } from '../types/index.js'; import { Database, Tables } from '../types/database.types.js'; type TaskRow = Tables<'tasks'>; -type DependencyRow = Tables<'task_dependencies'>; + +// Legacy type for backward compatibility +type DependencyRow = Tables<'task_dependencies'> & { + depends_on_task?: { display_id: string } | null; + depends_on_task_id?: string; +}; export class TaskMapper { /** * Maps database tasks to internal Task format + * @param dbTasks - Array of tasks from database + * @param dependencies - Either a Map of task_id to display_ids or legacy array format */ static mapDatabaseTasksToTasks( dbTasks: TaskRow[], - dbDependencies: DependencyRow[] + dependencies: Map | DependencyRow[] ): Task[] { if (!dbTasks || dbTasks.length === 0) { return []; } - // Group dependencies by task_id - const dependenciesByTaskId = this.groupDependenciesByTaskId(dbDependencies); + // Handle both Map and array formats for backward compatibility + const dependenciesByTaskId = + dependencies instanceof Map + ? dependencies + : this.groupDependenciesByTaskId(dependencies); // Separate parent tasks and subtasks const parentTasks = dbTasks.filter((t) => !t.parent_task_id); @@ -43,21 +53,23 @@ export class TaskMapper { ): Task { // Map subtasks const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => ({ - id: index + 1, // Use numeric ID for subtasks + id: subtask.display_id || String(index + 1), // Use display_id if available (API storage), fallback to numeric (file storage) parentId: dbTask.id, title: subtask.title, description: subtask.description || '', status: this.mapStatus(subtask.status), priority: this.mapPriority(subtask.priority), dependencies: dependenciesByTaskId.get(subtask.id) || [], - details: (subtask.metadata as any)?.details || '', - testStrategy: (subtask.metadata as any)?.testStrategy || '', + details: this.extractMetadataField(subtask.metadata, 'details', ''), + testStrategy: this.extractMetadataField( + subtask.metadata, + 'testStrategy', + '' + ), createdAt: subtask.created_at, updatedAt: subtask.updated_at, assignee: subtask.assignee_id || undefined, - complexity: subtask.complexity - ? this.mapComplexityToInternal(subtask.complexity) - : undefined + complexity: subtask.complexity ?? undefined })); return { @@ -67,22 +79,25 @@ export class TaskMapper { status: this.mapStatus(dbTask.status), priority: this.mapPriority(dbTask.priority), dependencies: dependenciesByTaskId.get(dbTask.id) || [], - details: (dbTask.metadata as any)?.details || '', - testStrategy: (dbTask.metadata as any)?.testStrategy || '', + details: this.extractMetadataField(dbTask.metadata, 'details', ''), + testStrategy: this.extractMetadataField( + dbTask.metadata, + 'testStrategy', + '' + ), subtasks, createdAt: dbTask.created_at, updatedAt: dbTask.updated_at, assignee: dbTask.assignee_id || undefined, - complexity: dbTask.complexity - ? this.mapComplexityToInternal(dbTask.complexity) - : undefined, + complexity: dbTask.complexity ?? undefined, effort: dbTask.estimated_hours || undefined, actualEffort: dbTask.actual_hours || undefined }; } /** - * Groups dependencies by task ID + * Groups dependencies by task ID (legacy method for backward compatibility) + * @deprecated Use DependencyFetcher.fetchDependenciesWithDisplayIds instead */ private static groupDependenciesByTaskId( dependencies: DependencyRow[] @@ -92,7 +107,14 @@ export class TaskMapper { if (dependencies) { for (const dep of dependencies) { const deps = dependenciesByTaskId.get(dep.task_id) || []; - deps.push(dep.depends_on_task_id); + // Handle both old format (UUID string) and new format (object with display_id) + const dependencyId = + typeof dep.depends_on_task === 'object' + ? dep.depends_on_task?.display_id + : dep.depends_on_task_id; + if (dependencyId) { + deps.push(dependencyId); + } dependenciesByTaskId.set(dep.task_id, deps); } } @@ -157,14 +179,38 @@ export class TaskMapper { } /** - * Maps numeric complexity to descriptive complexity + * Safely extracts a field from metadata JSON with runtime type validation + * @param metadata The metadata object (could be null or any type) + * @param field The field to extract + * @param defaultValue Default value if field doesn't exist + * @returns The extracted value if it matches the expected type, otherwise defaultValue */ - private static mapComplexityToInternal( - complexity: number - ): Task['complexity'] { - if (complexity <= 2) return 'simple'; - if (complexity <= 5) return 'moderate'; - if (complexity <= 8) return 'complex'; - return 'very-complex'; + private static extractMetadataField( + metadata: unknown, + field: string, + defaultValue: T + ): T { + if (!metadata || typeof metadata !== 'object') { + return defaultValue; + } + + const value = (metadata as Record)[field]; + + if (value === undefined) { + return defaultValue; + } + + // Runtime type validation: ensure value matches the type of defaultValue + const expectedType = typeof defaultValue; + const actualType = typeof value; + + if (expectedType !== actualType) { + console.warn( + `Type mismatch in metadata field "${field}": expected ${expectedType}, got ${actualType}. Using default value.` + ); + return defaultValue; + } + + return value as T; } } diff --git a/packages/tm-core/src/repositories/supabase/dependency-fetcher.ts b/packages/tm-core/src/repositories/supabase/dependency-fetcher.ts new file mode 100644 index 00000000..0f0ef97a --- /dev/null +++ b/packages/tm-core/src/repositories/supabase/dependency-fetcher.ts @@ -0,0 +1,68 @@ +import { SupabaseClient } from '@supabase/supabase-js'; +import { Database } from '../../types/database.types.js'; +import { DependencyWithDisplayId } from '../../types/repository-types.js'; + +/** + * Handles fetching and processing of task dependencies with display_ids + */ +export class DependencyFetcher { + constructor(private supabase: SupabaseClient) {} + + /** + * Fetches dependencies for given task IDs with display_ids joined + * @param taskIds Array of task IDs to fetch dependencies for + * @returns Map of task ID to array of dependency display_ids + */ + async fetchDependenciesWithDisplayIds( + taskIds: string[] + ): Promise> { + if (!taskIds || taskIds.length === 0) { + return new Map(); + } + + const { data, error } = await this.supabase + .from('task_dependencies') + .select(` + task_id, + depends_on_task:tasks!task_dependencies_depends_on_task_id_fkey ( + display_id + ) + `) + .in('task_id', taskIds); + + if (error) { + throw new Error(`Failed to fetch task dependencies: ${error.message}`); + } + + return this.processDependencyData(data as DependencyWithDisplayId[]); + } + + /** + * Processes raw dependency data into a map structure + */ + private processDependencyData( + dependencies: DependencyWithDisplayId[] + ): Map { + const dependenciesByTaskId = new Map(); + + if (!dependencies) { + return dependenciesByTaskId; + } + + for (const dep of dependencies) { + if (!dep.task_id) continue; + + const currentDeps = dependenciesByTaskId.get(dep.task_id) || []; + + // Extract display_id from the joined object + const displayId = dep.depends_on_task?.display_id; + if (displayId) { + currentDeps.push(displayId); + } + + dependenciesByTaskId.set(dep.task_id, currentDeps); + } + + return dependenciesByTaskId; + } +} diff --git a/packages/tm-core/src/repositories/supabase/index.ts b/packages/tm-core/src/repositories/supabase/index.ts new file mode 100644 index 00000000..f2e2e7da --- /dev/null +++ b/packages/tm-core/src/repositories/supabase/index.ts @@ -0,0 +1,5 @@ +/** + * Supabase repository implementations + */ +export { SupabaseTaskRepository } from './supabase-task-repository.js'; +export { DependencyFetcher } from './dependency-fetcher.js'; diff --git a/packages/tm-core/src/repositories/supabase-task-repository.ts b/packages/tm-core/src/repositories/supabase/supabase-task-repository.ts similarity index 53% rename from packages/tm-core/src/repositories/supabase-task-repository.ts rename to packages/tm-core/src/repositories/supabase/supabase-task-repository.ts index fdad96e2..14a26594 100644 --- a/packages/tm-core/src/repositories/supabase-task-repository.ts +++ b/packages/tm-core/src/repositories/supabase/supabase-task-repository.ts @@ -1,8 +1,13 @@ import { SupabaseClient } from '@supabase/supabase-js'; -import { Task } from '../types/index.js'; -import { Database } from '../types/database.types.js'; -import { TaskMapper } from '../mappers/TaskMapper.js'; -import { AuthManager } from '../auth/auth-manager.js'; +import { Task } from '../../types/index.js'; +import { Database, Json } from '../../types/database.types.js'; +import { TaskMapper } from '../../mappers/TaskMapper.js'; +import { AuthManager } from '../../auth/auth-manager.js'; +import { DependencyFetcher } from './dependency-fetcher.js'; +import { + TaskWithRelations, + TaskDatabaseUpdate +} from '../../types/repository-types.js'; import { z } from 'zod'; // Zod schema for task status validation @@ -29,18 +34,30 @@ const TaskUpdateSchema = z .partial(); export class SupabaseTaskRepository { - constructor(private supabase: SupabaseClient) {} + private dependencyFetcher: DependencyFetcher; + private authManager: AuthManager; - async getTasks(_projectId?: string): Promise { - // Get the current context to determine briefId - const authManager = AuthManager.getInstance(); - const context = authManager.getContext(); + constructor(private supabase: SupabaseClient) { + this.dependencyFetcher = new DependencyFetcher(supabase); + this.authManager = AuthManager.getInstance(); + } - if (!context || !context.briefId) { + /** + * Gets the current brief ID from auth context + * @throws {Error} If no brief is selected + */ + private getBriefIdOrThrow(): string { + const context = this.authManager.getContext(); + if (!context?.briefId) { throw new Error( 'No brief selected. Please select a brief first using: tm context brief' ); } + return context.briefId; + } + + async getTasks(_projectId?: string): Promise { + const briefId = this.getBriefIdOrThrow(); // Get all tasks for the brief using the exact query structure const { data: tasks, error } = await this.supabase @@ -54,7 +71,7 @@ export class SupabaseTaskRepository { description ) `) - .eq('brief_id', context.briefId) + .eq('brief_id', briefId) .order('position', { ascending: true }) .order('subtask_position', { ascending: true }) .order('created_at', { ascending: true }); @@ -67,38 +84,23 @@ export class SupabaseTaskRepository { return []; } - // Get all dependencies for these tasks - const taskIds = tasks.map((t: any) => t.id); - const { data: depsData, error: depsError } = await this.supabase - .from('task_dependencies') - .select('*') - .in('task_id', taskIds); - - if (depsError) { - throw new Error( - `Failed to fetch task dependencies: ${depsError.message}` - ); - } + // Type-safe task ID extraction + const typedTasks = tasks as TaskWithRelations[]; + const taskIds = typedTasks.map((t) => t.id); + const dependenciesMap = + await this.dependencyFetcher.fetchDependenciesWithDisplayIds(taskIds); // Use mapper to convert to internal format - return TaskMapper.mapDatabaseTasksToTasks(tasks, depsData || []); + return TaskMapper.mapDatabaseTasksToTasks(tasks, dependenciesMap); } async getTask(_projectId: string, taskId: string): Promise { - // Get the current context to determine briefId (projectId not used in Supabase context) - const authManager = AuthManager.getInstance(); - const context = authManager.getContext(); - - if (!context || !context.briefId) { - throw new Error( - 'No brief selected. Please select a brief first using: tm context brief' - ); - } + const briefId = this.getBriefIdOrThrow(); const { data, error } = await this.supabase .from('tasks') .select('*') - .eq('brief_id', context.briefId) + .eq('brief_id', briefId) .eq('display_id', taskId.toUpperCase()) .single(); @@ -109,30 +111,19 @@ export class SupabaseTaskRepository { throw new Error(`Failed to fetch task: ${error.message}`); } - // Get dependencies for this task - const { data: depsData } = await this.supabase - .from('task_dependencies') - .select('*') - .eq('task_id', taskId); - // Get subtasks if this is a parent task const { data: subtasksData } = await this.supabase .from('tasks') .select('*') - .eq('parent_task_id', taskId) + .eq('parent_task_id', data.id) .order('subtask_position', { ascending: true }); - // Create dependency map - const dependenciesByTaskId = new Map(); - if (depsData) { - dependenciesByTaskId.set( - taskId, - depsData.map( - (d: Database['public']['Tables']['task_dependencies']['Row']) => - d.depends_on_task_id - ) - ); - } + // Get all task IDs (parent + subtasks) to fetch dependencies + const allTaskIds = [data.id, ...(subtasksData?.map((st) => st.id) || [])]; + + // Fetch dependencies using the dedicated fetcher + const dependenciesByTaskId = + await this.dependencyFetcher.fetchDependenciesWithDisplayIds(allTaskIds); // Use mapper to convert single task return TaskMapper.mapDatabaseTaskToTask( @@ -147,15 +138,7 @@ export class SupabaseTaskRepository { taskId: string, updates: Partial ): Promise { - // Get the current context to determine briefId - const authManager = AuthManager.getInstance(); - const context = authManager.getContext(); - - if (!context || !context.briefId) { - throw new Error( - 'No brief selected. Please select a brief first using: tm context brief' - ); - } + const briefId = this.getBriefIdOrThrow(); // Validate updates using Zod schema try { @@ -170,22 +153,50 @@ export class SupabaseTaskRepository { throw error; } - // Convert Task fields to database fields - only include fields that actually exist in the database - const dbUpdates: any = {}; + // Convert Task fields to database fields with proper typing + const dbUpdates: TaskDatabaseUpdate = {}; if (updates.title !== undefined) dbUpdates.title = updates.title; if (updates.description !== undefined) dbUpdates.description = updates.description; if (updates.status !== undefined) dbUpdates.status = this.mapStatusToDatabase(updates.status); - if (updates.priority !== undefined) dbUpdates.priority = updates.priority; - // Skip fields that don't exist in database schema: details, testStrategy, etc. + if (updates.priority !== undefined) + dbUpdates.priority = this.mapPriorityToDatabase(updates.priority); + + // Handle metadata fields (details, testStrategy, etc.) + // Load existing metadata to preserve fields not being updated + const { data: existingMetadataRow, error: existingMetadataError } = + await this.supabase + .from('tasks') + .select('metadata') + .eq('brief_id', briefId) + .eq('display_id', taskId.toUpperCase()) + .single(); + + if (existingMetadataError) { + throw new Error( + `Failed to load existing task metadata: ${existingMetadataError.message}` + ); + } + + const metadata: Record = { + ...((existingMetadataRow?.metadata as Record) ?? {}) + }; + + if (updates.details !== undefined) metadata.details = updates.details; + if (updates.testStrategy !== undefined) + metadata.testStrategy = updates.testStrategy; + + if (Object.keys(metadata).length > 0) { + dbUpdates.metadata = metadata as Json; + } // Update the task const { error } = await this.supabase .from('tasks') .update(dbUpdates) - .eq('brief_id', context.briefId) + .eq('brief_id', briefId) .eq('display_id', taskId.toUpperCase()); if (error) { @@ -221,4 +232,25 @@ export class SupabaseTaskRepository { ); } } + + /** + * Maps internal priority to database priority + * Task Master uses 'critical', database uses 'urgent' + */ + private mapPriorityToDatabase( + priority: string + ): Database['public']['Enums']['task_priority'] { + switch (priority) { + case 'critical': + return 'urgent'; + case 'low': + case 'medium': + case 'high': + return priority as Database['public']['Enums']['task_priority']; + default: + throw new Error( + `Invalid task priority: ${priority}. Valid priorities are: low, medium, high, critical` + ); + } + } } diff --git a/packages/tm-core/src/storage/api-storage.ts b/packages/tm-core/src/storage/api-storage.ts index b62c1e92..d7fe906d 100644 --- a/packages/tm-core/src/storage/api-storage.ts +++ b/packages/tm-core/src/storage/api-storage.ts @@ -16,7 +16,7 @@ import type { } from '../types/index.js'; import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js'; import { TaskRepository } from '../repositories/task-repository.interface.js'; -import { SupabaseTaskRepository } from '../repositories/supabase-task-repository.js'; +import { SupabaseTaskRepository } from '../repositories/supabase/index.js'; import { SupabaseClient } from '@supabase/supabase-js'; import { AuthManager } from '../auth/auth-manager.js'; diff --git a/packages/tm-core/src/types/index.ts b/packages/tm-core/src/types/index.ts index 38d54084..de013fe8 100644 --- a/packages/tm-core/src/types/index.ts +++ b/packages/tm-core/src/types/index.ts @@ -82,10 +82,11 @@ export interface Task { } /** - * Subtask interface extending Task with numeric ID + * Subtask interface extending Task + * ID can be number (file storage) or string (API storage with display_id) */ export interface Subtask extends Omit { - id: number; + id: number | string; parentId: string; subtasks?: never; // Subtasks cannot have their own subtasks } diff --git a/packages/tm-core/src/types/repository-types.ts b/packages/tm-core/src/types/repository-types.ts new file mode 100644 index 00000000..3afaac54 --- /dev/null +++ b/packages/tm-core/src/types/repository-types.ts @@ -0,0 +1,83 @@ +/** + * Type definitions for repository operations + */ +import { Database, Tables } from './database.types.js'; + +/** + * Task row from database with optional joined relations + */ +export interface TaskWithRelations extends Tables<'tasks'> { + document?: { + id: string; + document_name: string; + title: string; + description: string | null; + } | null; +} + +/** + * Dependency row with joined display_id + */ +export interface DependencyWithDisplayId { + task_id: string; + depends_on_task: { + display_id: string; + } | null; +} + +/** + * Task metadata structure + */ +export interface TaskMetadata { + details?: string; + testStrategy?: string; + [key: string]: unknown; // Allow additional fields but be explicit +} + +/** + * Database update payload for tasks + */ +export type TaskDatabaseUpdate = + Database['public']['Tables']['tasks']['Update']; +/** + * Configuration for task queries + */ +export interface TaskQueryConfig { + briefId: string; + includeSubtasks?: boolean; + includeDependencies?: boolean; + includeDocument?: boolean; +} + +/** + * Result of a task fetch operation + */ +export interface TaskFetchResult { + task: Tables<'tasks'>; + subtasks: Tables<'tasks'>[]; + dependencies: Map; +} + +/** + * Task validation errors + */ +export class TaskValidationError extends Error { + constructor( + message: string, + public readonly field: string, + public readonly value: unknown + ) { + super(message); + this.name = 'TaskValidationError'; + } +} + +/** + * Context validation errors + */ +export class ContextValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ContextValidationError'; + } +}