feat: add api-storage improvements (#1278)
This commit is contained in:
@@ -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<Database>) {}
|
||||
|
||||
/**
|
||||
* 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<Map<string, string[]>> {
|
||||
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<string, string[]> {
|
||||
const dependenciesByTaskId = new Map<string, string[]>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
5
packages/tm-core/src/repositories/supabase/index.ts
Normal file
5
packages/tm-core/src/repositories/supabase/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Supabase repository implementations
|
||||
*/
|
||||
export { SupabaseTaskRepository } from './supabase-task-repository.js';
|
||||
export { DependencyFetcher } from './dependency-fetcher.js';
|
||||
@@ -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<Database>) {}
|
||||
private dependencyFetcher: DependencyFetcher;
|
||||
private authManager: AuthManager;
|
||||
|
||||
async getTasks(_projectId?: string): Promise<Task[]> {
|
||||
// Get the current context to determine briefId
|
||||
const authManager = AuthManager.getInstance();
|
||||
const context = authManager.getContext();
|
||||
constructor(private supabase: SupabaseClient<Database>) {
|
||||
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<Task[]> {
|
||||
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<Task | null> {
|
||||
// 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<string, string[]>();
|
||||
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<Task>
|
||||
): Promise<Task> {
|
||||
// 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<string, unknown> = {
|
||||
...((existingMetadataRow?.metadata as Record<string, unknown>) ?? {})
|
||||
};
|
||||
|
||||
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`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user