diff --git a/archon-ui-main/src/features/projects/documents/components/VersionHistoryModal.tsx b/archon-ui-main/src/features/projects/documents/components/VersionHistoryModal.tsx index 760e9c1b..98433e99 100644 --- a/archon-ui-main/src/features/projects/documents/components/VersionHistoryModal.tsx +++ b/archon-ui-main/src/features/projects/documents/components/VersionHistoryModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Clock, RotateCcw, Calendar, User, FileText, Diff, GitBranch, AlertTriangle } from 'lucide-react'; -// import { projectService } from '../../../../services/projectService'; // TODO: Uncomment when API methods are implemented +import { documentService } from '../services'; import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from '../../../ui/primitives'; import { useToast } from '../../../../contexts/ToastContext'; import { cn, glassmorphism } from '../../../ui/primitives/styles'; @@ -51,10 +51,8 @@ export const VersionHistoryModal = ({ const loadVersions = async () => { setLoading(true); try { - // TODO: Implement getVersions in projectService - // const response = await projectService.getVersions(projectId, fieldName); - // setVersions(response.versions || []); - setVersions([]); // Temporary until API is implemented + const response = await documentService.getDocumentVersionHistory(projectId, fieldName); + setVersions(response || []); } catch (error) { console.error('Failed to load versions:', error); showToast('Failed to load version history', 'error'); @@ -73,15 +71,13 @@ export const VersionHistoryModal = ({ setRestoring(true); try { - // TODO: Implement restoreVersion in projectService - // await projectService.restoreVersion( - // projectId, - // fieldName, - // versionToRestore.version_number, - // 'User' - // ); + await documentService.restoreDocumentVersion( + projectId, + versionToRestore.version_number, + fieldName + ); - showToast(`Version restore not yet implemented`, 'info'); + showToast(`Version ${versionToRestore.version_number} restored successfully`, 'success'); setShowRestoreConfirm(false); setVersionToRestore(null); onRestore?.(); diff --git a/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts b/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts index 4010073d..b1be05fd 100644 --- a/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts +++ b/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { projectService } from '../../../../services/projectService'; +import { projectService } from '../../services'; import { useToast } from '../../../../contexts/ToastContext'; import type { ProjectDocument } from '../types'; diff --git a/archon-ui-main/src/features/projects/documents/services/documentService.ts b/archon-ui-main/src/features/projects/documents/services/documentService.ts new file mode 100644 index 00000000..53d9c6e3 --- /dev/null +++ b/archon-ui-main/src/features/projects/documents/services/documentService.ts @@ -0,0 +1,50 @@ +/** + * Document Management Service + * Focused service for document and versioning operations + */ + +import { callAPI } from '../../shared/api'; + +export const documentService = { + /** + * Get version history for project documents + */ + async getDocumentVersionHistory(projectId: string, fieldName: string = 'docs'): Promise { + try { + const response = await callAPI<{versions: any[]}>(`/api/projects/${projectId}/versions?field_name=${fieldName}`); + return response.versions || []; + } catch (error) { + console.error(`Failed to get document version history for project ${projectId}:`, error); + throw error; + } + }, + + /** + * Get content of a specific document version for preview + */ + async getVersionContent(projectId: string, versionNumber: number, fieldName: string = 'docs'): Promise { + try { + const response = await callAPI<{content: any, version: any}>(`/api/projects/${projectId}/versions/${fieldName}/${versionNumber}`); + return response; + } catch (error) { + console.error(`Failed to get version ${versionNumber} content for project ${projectId}:`, error); + throw error; + } + }, + + /** + * Restore a project document field to a specific version + */ + async restoreDocumentVersion(projectId: string, versionNumber: number, fieldName: string = 'docs'): Promise { + try { + const response = await callAPI(`/api/projects/${projectId}/versions/${fieldName}/${versionNumber}/restore`, { + method: 'POST' + }); + + return response; + } catch (error) { + console.error(`Failed to restore version ${versionNumber} for project ${projectId}:`, error); + throw error; + } + }, +}; \ No newline at end of file diff --git a/archon-ui-main/src/features/projects/documents/services/index.ts b/archon-ui-main/src/features/projects/documents/services/index.ts index 0216639e..2cfd6631 100644 --- a/archon-ui-main/src/features/projects/documents/services/index.ts +++ b/archon-ui-main/src/features/projects/documents/services/index.ts @@ -2,10 +2,7 @@ * Document Services * * Service layer for document operations. - * Currently using projectService from the main services directory. - * This file exists to maintain structural consistency. + * Part of the vertical slice architecture migration. */ -// Document services are currently handled by projectService -// This file is a placeholder for future document-specific services -export {}; \ No newline at end of file +export { documentService } from './documentService'; \ No newline at end of file diff --git a/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts b/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts index bdf48414..274e2a7e 100644 --- a/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts +++ b/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { projectService } from '../../../services/projectService'; +import { projectService, taskService } from '../services'; import type { Project, CreateProjectRequest, UpdateProjectRequest } from '../types'; import { useToast } from '../../../contexts/ToastContext'; import { useSmartPolling } from '../../ui/hooks'; @@ -33,7 +33,7 @@ export function useProjects() { export function useTaskCounts() { return useQuery({ queryKey: projectKeys.taskCounts(), - queryFn: () => projectService.getTaskCountsForAllProjects(), + queryFn: () => taskService.getTaskCountsForAllProjects(), refetchInterval: false, // Don't poll, only refetch manually staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); diff --git a/archon-ui-main/src/features/projects/services/index.ts b/archon-ui-main/src/features/projects/services/index.ts index 18059adb..64766f8e 100644 --- a/archon-ui-main/src/features/projects/services/index.ts +++ b/archon-ui-main/src/features/projects/services/index.ts @@ -2,14 +2,15 @@ * Project Services * * All API communication and business logic for the projects feature. - * Will replace/consolidate: - * - src/services/projectService.ts (614 lines -> split into focused services) - * - * Services: - * - projectService: Project CRUD operations - * - taskService: Task management operations - * - documentService: Document operations - * - versionService: Document versioning + * Replaces the monolithic src/services/projectService.ts with focused services. */ -// Services will be exported here as they're migrated \ No newline at end of file +// Export project-specific services +export { projectService } from './projectService'; + +// Re-export other services for convenience +export { taskService } from '../tasks/services/taskService'; +export { documentService } from '../documents/services/documentService'; + +// Export shared utilities +export * from '../shared/api'; \ No newline at end of file diff --git a/archon-ui-main/src/features/projects/services/projectService.ts b/archon-ui-main/src/features/projects/services/projectService.ts new file mode 100644 index 00000000..accccc61 --- /dev/null +++ b/archon-ui-main/src/features/projects/services/projectService.ts @@ -0,0 +1,187 @@ +/** + * Project Management Service + * Focused service for project CRUD operations only + */ + +import type { + Project, + CreateProjectRequest, + UpdateProjectRequest +} from '../types'; + +import { + validateCreateProject, + validateUpdateProject, +} from '../schemas'; + +import { + callAPI, + formatValidationErrors, + ValidationError, + formatRelativeTime +} from '../shared/api'; + +export const projectService = { + /** + * Get all projects + */ + async listProjects(): Promise { + try { + console.log('[PROJECT SERVICE] Fetching projects from API'); + const response = await callAPI<{ projects: Project[] }>('/api/projects'); + console.log('[PROJECT SERVICE] Raw API response:', response); + + const projects = response.projects || []; + console.log('[PROJECT SERVICE] Projects array length:', projects.length); + + // Debug raw pinned values + projects.forEach((p: any) => { + console.log(`[PROJECT SERVICE] Raw project: ${p.title}, pinned=${p.pinned} (type: ${typeof p.pinned})`); + }); + + // Add computed UI properties + const processedProjects = projects.map((project: Project) => { + // Debug the raw pinned value + console.log(`[PROJECT SERVICE] Processing ${project.title}: raw pinned=${project.pinned} (type: ${typeof project.pinned})`); + + const processed = { + ...project, + // Ensure pinned is properly handled as boolean + pinned: project.pinned === true || project.pinned === 'true', + progress: project.progress || 0, + updated: project.updated || formatRelativeTime(project.updated_at) + }; + console.log(`[PROJECT SERVICE] Processed project ${project.id} (${project.title}), pinned=${processed.pinned} (type: ${typeof processed.pinned})`); + return processed; + }); + + console.log('[PROJECT SERVICE] All processed projects:', processedProjects.map(p => ({id: p.id, title: p.title, pinned: p.pinned}))); + return processedProjects; + } catch (error) { + console.error('Failed to list projects:', error); + throw error; + } + }, + + /** + * Get a specific project by ID + */ + async getProject(projectId: string): Promise { + try { + const project = await callAPI(`/api/projects/${projectId}`); + + return { + ...project, + progress: project.progress || 0, + updated: project.updated || formatRelativeTime(project.updated_at) + }; + } catch (error) { + console.error(`Failed to get project ${projectId}:`, error); + throw error; + } + }, + + /** + * Create a new project + */ + async createProject(projectData: CreateProjectRequest): Promise<{ project_id: string; project: any; status: string; message: string }> { + // Validate input + console.log('[PROJECT SERVICE] Validating project data:', projectData); + const validation = validateCreateProject(projectData); + if (!validation.success) { + console.error('[PROJECT SERVICE] Validation failed:', validation.error); + throw new ValidationError(formatValidationErrors(validation.error)); + } + console.log('[PROJECT SERVICE] Validation passed:', validation.data); + + try { + console.log('[PROJECT SERVICE] Sending project creation request:', validation.data); + const response = await callAPI<{ project_id: string; project: any; status: string; message: string }>('/api/projects', { + method: 'POST', + body: JSON.stringify(validation.data) + }); + + console.log('[PROJECT SERVICE] Project creation response:', response); + return response; + } catch (error) { + console.error('[PROJECT SERVICE] Failed to initiate project creation:', error); + if (error instanceof Error) { + console.error('[PROJECT SERVICE] Error details:', { + message: error.message, + name: error.name + }); + } + throw error; + } + }, + + /** + * Update an existing project + */ + async updateProject(projectId: string, updates: UpdateProjectRequest): Promise { + // Validate input + console.log(`[PROJECT SERVICE] Updating project ${projectId} with data:`, updates); + const validation = validateUpdateProject(updates); + if (!validation.success) { + console.error(`[PROJECT SERVICE] Validation failed:`, validation.error); + throw new ValidationError(formatValidationErrors(validation.error)); + } + + try { + console.log(`[PROJECT SERVICE] Sending API request to update project ${projectId}`, validation.data); + const project = await callAPI(`/api/projects/${projectId}`, { + method: 'PUT', + body: JSON.stringify(validation.data) + }); + + console.log(`[PROJECT SERVICE] API update response:`, project); + + // Ensure pinned property is properly handled as boolean + const processedProject = { + ...project, + pinned: project.pinned === true, + progress: project.progress || 0, + updated: formatRelativeTime(project.updated_at) + }; + + console.log(`[PROJECT SERVICE] Final processed project:`, { + id: processedProject.id, + title: processedProject.title, + pinned: processedProject.pinned + }); + + return processedProject; + } catch (error) { + console.error(`Failed to update project ${projectId}:`, error); + throw error; + } + }, + + /** + * Delete a project + */ + async deleteProject(projectId: string): Promise { + try { + await callAPI(`/api/projects/${projectId}`, { + method: 'DELETE' + }); + + } catch (error) { + console.error(`Failed to delete project ${projectId}:`, error); + throw error; + } + }, + + /** + * Get features from a project's features JSONB field + */ + async getProjectFeatures(projectId: string): Promise<{ features: any[]; count: number }> { + try { + const response = await callAPI<{ features: any[]; count: number }>(`/api/projects/${projectId}/features`); + return response; + } catch (error) { + console.error(`Failed to get features for project ${projectId}:`, error); + throw error; + } + }, +}; \ No newline at end of file diff --git a/archon-ui-main/src/features/projects/shared/api.ts b/archon-ui-main/src/features/projects/shared/api.ts new file mode 100644 index 00000000..4537dc2e --- /dev/null +++ b/archon-ui-main/src/features/projects/shared/api.ts @@ -0,0 +1,108 @@ +/** + * Shared API utilities for project features + * Common error handling and API calling functions + */ + +// API configuration - use relative URL to go through Vite proxy +const API_BASE_URL = '/api'; + +// Error classes +export class ProjectServiceError extends Error { + constructor(message: string, public code?: string, public statusCode?: number) { + super(message); + this.name = 'ProjectServiceError'; + } +} + +export class ValidationError extends ProjectServiceError { + constructor(message: string) { + super(message, 'VALIDATION_ERROR', 400); + this.name = 'ValidationError'; + } +} + +export class MCPToolError extends ProjectServiceError { + constructor(message: string, public toolName: string) { + super(message, 'MCP_TOOL_ERROR', 500); + this.name = 'MCPToolError'; + } +} + +// Helper function to format validation errors +export function formatValidationErrors(errors: any): string { + return errors.errors + .map((error: any) => `${error.path.join('.')}: ${error.message}`) + .join(', '); +} + +// Helper function to call FastAPI endpoints directly +export async function callAPI(endpoint: string, options: RequestInit = {}): Promise { + try { + // Remove /api prefix if it exists since API_BASE_URL already includes it + const cleanEndpoint = endpoint.startsWith('/api') ? endpoint.substring(4) : endpoint; + const response = await fetch(`${API_BASE_URL}${cleanEndpoint}`, { + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options + }); + + if (!response.ok) { + // Try to get error details from response body + let errorMessage = `HTTP error! status: ${response.status}`; + try { + const errorBody = await response.text(); + if (errorBody) { + const errorJson = JSON.parse(errorBody); + errorMessage = errorJson.detail || errorJson.error || errorMessage; + } + } catch (e) { + // Ignore parse errors, use default message + } + + throw new ProjectServiceError( + errorMessage, + 'HTTP_ERROR', + response.status + ); + } + + const result = await response.json(); + + // Check if response has error field (from FastAPI error format) + if (result.error) { + throw new ProjectServiceError( + result.error, + 'API_ERROR', + response.status + ); + } + + return result as T; + } catch (error) { + if (error instanceof ProjectServiceError) { + throw error; + } + + throw new ProjectServiceError( + `Failed to call API ${endpoint}: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'NETWORK_ERROR', + 500 + ); + } +} + +// Utility function for relative time formatting +export function formatRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) return 'just now'; + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`; + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`; + if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`; + + return `${Math.floor(diffInSeconds / 604800)} weeks ago`; +} \ No newline at end of file diff --git a/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts b/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts index 57a3cf05..1915d5d3 100644 --- a/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts +++ b/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { projectService } from '../../../../services/projectService'; +import { taskService } from '../services'; import { useToast } from '../../../../contexts/ToastContext'; import { useSmartPolling } from '../../../ui/hooks'; import type { Task, CreateTaskRequest, UpdateTaskRequest } from '../types'; @@ -16,7 +16,7 @@ export function useProjectTasks(projectId: string | undefined, enabled = true) { return useQuery({ queryKey: projectId ? taskKeys.all(projectId) : ['tasks-undefined'], - queryFn: () => projectId ? projectService.getTasksByProject(projectId) : Promise.reject('No project ID'), + queryFn: () => projectId ? taskService.getTasksByProject(projectId) : Promise.reject('No project ID'), enabled: !!projectId && enabled, refetchInterval, // Smart interval based on page visibility/focus staleTime: 2000, // Consider data stale after 2 seconds @@ -29,7 +29,7 @@ export function useCreateTask() { const { showToast } = useToast(); return useMutation({ - mutationFn: (taskData: CreateTaskRequest) => projectService.createTask(taskData), + mutationFn: (taskData: CreateTaskRequest) => taskService.createTask(taskData), onSuccess: (_data, variables) => { // Invalidate tasks for the project queryClient.invalidateQueries({ queryKey: taskKeys.all(variables.project_id) }); @@ -51,7 +51,7 @@ export function useUpdateTask(projectId: string) { return useMutation({ mutationFn: ({ taskId, updates }: { taskId: string; updates: UpdateTaskRequest }) => - projectService.updateTask(taskId, updates), + taskService.updateTask(taskId, updates), onMutate: async ({ taskId, updates }) => { // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: taskKeys.all(projectId) }); @@ -97,7 +97,7 @@ export function useDeleteTask(projectId: string) { const { showToast } = useToast(); return useMutation({ - mutationFn: (taskId: string) => projectService.deleteTask(taskId), + mutationFn: (taskId: string) => taskService.deleteTask(taskId), onMutate: async (taskId) => { // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: taskKeys.all(projectId) }); @@ -137,7 +137,7 @@ export function useTaskCounts() { return useQuery({ queryKey: taskKeys.counts(), - queryFn: () => projectService.getTaskCountsForAllProjects(), + queryFn: () => taskService.getTaskCountsForAllProjects(), refetchInterval: false, // Don't poll, only refetch manually staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); diff --git a/archon-ui-main/src/features/projects/tasks/services/index.ts b/archon-ui-main/src/features/projects/tasks/services/index.ts new file mode 100644 index 00000000..3d26ee24 --- /dev/null +++ b/archon-ui-main/src/features/projects/tasks/services/index.ts @@ -0,0 +1,8 @@ +/** + * Task Services + * + * Service layer for task operations. + * Part of the vertical slice architecture migration. + */ + +export { taskService } from './taskService'; \ No newline at end of file diff --git a/archon-ui-main/src/features/projects/tasks/services/taskService.ts b/archon-ui-main/src/features/projects/tasks/services/taskService.ts new file mode 100644 index 00000000..54e38595 --- /dev/null +++ b/archon-ui-main/src/features/projects/tasks/services/taskService.ts @@ -0,0 +1,192 @@ +/** + * Task Management Service + * Focused service for task CRUD operations only + */ + +import type { + Task, + CreateTaskRequest, + UpdateTaskRequest, + DatabaseTaskStatus, + TaskCounts +} from '../types'; + +import { + validateCreateTask, + validateUpdateTask, + validateUpdateTaskStatus, +} from '../schemas'; + +import { + callAPI, + formatValidationErrors, + ValidationError +} from '../../shared/api'; + +export const taskService = { + /** + * Get all tasks for a project + */ + async getTasksByProject(projectId: string): Promise { + try { + const tasks = await callAPI(`/api/projects/${projectId}/tasks`); + + // Convert database tasks to UI tasks with status mapping + return tasks; + } catch (error) { + console.error(`Failed to get tasks for project ${projectId}:`, error); + throw error; + } + }, + + /** + * Get a specific task by ID + */ + async getTask(taskId: string): Promise { + try { + const task = await callAPI(`/api/tasks/${taskId}`); + return task; + } catch (error) { + console.error(`Failed to get task ${taskId}:`, error); + throw error; + } + }, + + /** + * Create a new task + */ + async createTask(taskData: CreateTaskRequest): Promise { + // Validate input + const validation = validateCreateTask(taskData); + if (!validation.success) { + throw new ValidationError(formatValidationErrors(validation.error)); + } + + try { + // The validation.data already has defaults from schema + const requestData = validation.data; + + const task = await callAPI('/api/tasks', { + method: 'POST', + body: JSON.stringify(requestData) + }); + + return task; + } catch (error) { + console.error('Failed to create task:', error); + throw error; + } + }, + + /** + * Update an existing task + */ + async updateTask(taskId: string, updates: UpdateTaskRequest): Promise { + // Validate input + const validation = validateUpdateTask(updates); + if (!validation.success) { + throw new ValidationError(formatValidationErrors(validation.error)); + } + + try { + const task = await callAPI(`/api/tasks/${taskId}`, { + method: 'PUT', + body: JSON.stringify(validation.data) + }); + + return task; + } catch (error) { + console.error(`Failed to update task ${taskId}:`, error); + throw error; + } + }, + + /** + * Update task status (for drag & drop operations) + */ + async updateTaskStatus(taskId: string, status: DatabaseTaskStatus): Promise { + // Validate input + const validation = validateUpdateTaskStatus({ task_id: taskId, status: status }); + if (!validation.success) { + throw new ValidationError(formatValidationErrors(validation.error)); + } + + try { + // Use the standard update task endpoint with JSON body + const task = await callAPI(`/api/tasks/${taskId}`, { + method: 'PUT', + body: JSON.stringify({ status }) + }); + + return task; + } catch (error) { + console.error(`Failed to update task status ${taskId}:`, error); + throw error; + } + }, + + /** + * Delete a task + */ + async deleteTask(taskId: string): Promise { + try { + await callAPI(`/api/tasks/${taskId}`, { + method: 'DELETE' + }); + + } catch (error) { + console.error(`Failed to delete task ${taskId}:`, error); + throw error; + } + }, + + /** + * Update task order for better drag-and-drop support + */ + async updateTaskOrder(taskId: string, newOrder: number, newStatus?: DatabaseTaskStatus): Promise { + try { + const updates: UpdateTaskRequest = { + task_order: newOrder + }; + + if (newStatus) { + updates.status = newStatus; + } + + const task = await this.updateTask(taskId, updates); + + return task; + } catch (error) { + console.error(`Failed to update task order for ${taskId}:`, error); + throw error; + } + }, + + /** + * Get tasks by status across all projects + */ + async getTasksByStatus(status: DatabaseTaskStatus): Promise { + try { + // Note: This method requires cross-project access + // For now, we'll throw an error suggesting to use project-scoped queries + throw new Error('getTasksByStatus requires cross-project access. Use getTasksByProject instead.'); + } catch (error) { + console.error(`Failed to get tasks by status ${status}:`, error); + throw error; + } + }, + + /** + * Get task counts for all projects in a single batch request + * Optimized endpoint to avoid N+1 query problem + */ + async getTaskCountsForAllProjects(): Promise> { + try { + const response = await callAPI>('/api/projects/task-counts'); + return response || {}; + } catch (error) { + console.error('Failed to get task counts for all projects:', error); + throw error; + } + }, +}; \ No newline at end of file diff --git a/archon-ui-main/src/services/projectService.ts b/archon-ui-main/src/services/projectService.ts deleted file mode 100644 index bc0081de..00000000 --- a/archon-ui-main/src/services/projectService.ts +++ /dev/null @@ -1,558 +0,0 @@ -// Project Management Service Layer -// Integrates with MCP backend tools via API wrapper - -import type { - Project, - Task, - CreateProjectRequest, - UpdateProjectRequest, - CreateTaskRequest, - UpdateTaskRequest, - DatabaseTaskStatus, - TaskCounts -} from '../features/projects/types'; - -import { - validateCreateProject, - validateUpdateProject, -} from '../features/projects/schemas'; - -import { - validateCreateTask, - validateUpdateTask, - validateUpdateTaskStatus, -} from '../features/projects/tasks/schemas'; - -// Helper function to format validation errors -function formatValidationErrors(errors: any): string { - return errors.errors - .map((error: any) => `${error.path.join('.')}: ${error.message}`) - .join(', '); -} - -// No status mapping needed - using database values directly - -// Document interface for type safety -export interface Document { - id: string; - project_id: string; - title: string; - content: any; - document_type: string; - metadata?: Record; - tags?: string[]; - author?: string; - created_at: string; - updated_at: string; -} - -// API configuration - use relative URL to go through Vite proxy -const API_BASE_URL = '/api'; - - -// Error classes -export class ProjectServiceError extends Error { - constructor(message: string, public code?: string, public statusCode?: number) { - super(message); - this.name = 'ProjectServiceError'; - } -} - -export class ValidationError extends ProjectServiceError { - constructor(message: string) { - super(message, 'VALIDATION_ERROR', 400); - this.name = 'ValidationError'; - } -} - -export class MCPToolError extends ProjectServiceError { - constructor(message: string, public toolName: string) { - super(message, 'MCP_TOOL_ERROR', 500); - this.name = 'MCPToolError'; - } -} - -// Helper function to call FastAPI endpoints directly -async function callAPI(endpoint: string, options: RequestInit = {}): Promise { - try { - // Remove /api prefix if it exists since API_BASE_URL already includes it - const cleanEndpoint = endpoint.startsWith('/api') ? endpoint.substring(4) : endpoint; - const response = await fetch(`${API_BASE_URL}${cleanEndpoint}`, { - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - ...options - }); - - if (!response.ok) { - // Try to get error details from response body - let errorMessage = `HTTP error! status: ${response.status}`; - try { - const errorBody = await response.text(); - if (errorBody) { - const errorJson = JSON.parse(errorBody); - errorMessage = errorJson.detail || errorJson.error || errorMessage; - } - } catch (e) { - // Ignore parse errors, use default message - } - - throw new ProjectServiceError( - errorMessage, - 'HTTP_ERROR', - response.status - ); - } - - const result = await response.json(); - - // Check if response has error field (from FastAPI error format) - if (result.error) { - throw new ProjectServiceError( - result.error, - 'API_ERROR', - response.status - ); - } - - return result as T; - } catch (error) { - if (error instanceof ProjectServiceError) { - throw error; - } - - throw new ProjectServiceError( - `Failed to call API ${endpoint}: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'NETWORK_ERROR', - 500 - ); - } -} - -// Project Management Service -export const projectService = { - // ==================== PROJECT OPERATIONS ==================== - - /** - * Get all projects - */ - async listProjects(): Promise { - try { - console.log('[PROJECT SERVICE] Fetching projects from API'); - const response = await callAPI<{ projects: Project[] }>('/api/projects'); - console.log('[PROJECT SERVICE] Raw API response:', response); - - const projects = response.projects || []; - console.log('[PROJECT SERVICE] Projects array length:', projects.length); - - // Debug raw pinned values - projects.forEach((p: any) => { - console.log(`[PROJECT SERVICE] Raw project: ${p.title}, pinned=${p.pinned} (type: ${typeof p.pinned})`); - }); - - // Add computed UI properties - const processedProjects = projects.map((project: Project) => { - // Debug the raw pinned value - console.log(`[PROJECT SERVICE] Processing ${project.title}: raw pinned=${project.pinned} (type: ${typeof project.pinned})`); - - const processed = { - ...project, - // Ensure pinned is properly handled as boolean - pinned: project.pinned === true || project.pinned === 'true', - progress: project.progress || 0, - updated: project.updated || this.formatRelativeTime(project.updated_at) - }; - console.log(`[PROJECT SERVICE] Processed project ${project.id} (${project.title}), pinned=${processed.pinned} (type: ${typeof processed.pinned})`); - return processed; - }); - - console.log('[PROJECT SERVICE] All processed projects:', processedProjects.map(p => ({id: p.id, title: p.title, pinned: p.pinned}))); - return processedProjects; - } catch (error) { - console.error('Failed to list projects:', error); - throw error; - } - }, - - /** - * Get a specific project by ID - */ - async getProject(projectId: string): Promise { - try { - const project = await callAPI(`/api/projects/${projectId}`); - - return { - ...project, - progress: project.progress || 0, - updated: project.updated || this.formatRelativeTime(project.updated_at) - }; - } catch (error) { - console.error(`Failed to get project ${projectId}:`, error); - throw error; - } - }, - - /** - * Create a new project - */ - async createProject(projectData: CreateProjectRequest): Promise<{ project_id: string; project: any; status: string; message: string }> { - // Validate input - console.log('[PROJECT SERVICE] Validating project data:', projectData); - const validation = validateCreateProject(projectData); - if (!validation.success) { - console.error('[PROJECT SERVICE] Validation failed:', validation.error); - throw new ValidationError(formatValidationErrors(validation.error)); - } - console.log('[PROJECT SERVICE] Validation passed:', validation.data); - - try { - console.log('[PROJECT SERVICE] Sending project creation request:', validation.data); - const response = await callAPI<{ project_id: string; project: any; status: string; message: string }>('/api/projects', { - method: 'POST', - body: JSON.stringify(validation.data) - }); - - console.log('[PROJECT SERVICE] Project creation response:', response); - return response; - } catch (error) { - console.error('[PROJECT SERVICE] Failed to initiate project creation:', error); - if (error instanceof ProjectServiceError) { - console.error('[PROJECT SERVICE] Error details:', { - message: error.message, - code: error.code, - statusCode: error.statusCode - }); - } - throw error; - } - }, - - /** - * Update an existing project - */ - async updateProject(projectId: string, updates: UpdateProjectRequest): Promise { - // Validate input - console.log(`[PROJECT SERVICE] Updating project ${projectId} with data:`, updates); - const validation = validateUpdateProject(updates); - if (!validation.success) { - console.error(`[PROJECT SERVICE] Validation failed:`, validation.error); - throw new ValidationError(formatValidationErrors(validation.error)); - } - - try { - console.log(`[PROJECT SERVICE] Sending API request to update project ${projectId}`, validation.data); - const project = await callAPI(`/api/projects/${projectId}`, { - method: 'PUT', - body: JSON.stringify(validation.data) - }); - - console.log(`[PROJECT SERVICE] API update response:`, project); - - - // Ensure pinned property is properly handled as boolean - const processedProject = { - ...project, - pinned: project.pinned === true, - progress: project.progress || 0, - updated: this.formatRelativeTime(project.updated_at) - }; - - console.log(`[PROJECT SERVICE] Final processed project:`, { - id: processedProject.id, - title: processedProject.title, - pinned: processedProject.pinned - }); - - return processedProject; - } catch (error) { - console.error(`Failed to update project ${projectId}:`, error); - throw error; - } - }, - - /** - * Delete a project - */ - async deleteProject(projectId: string): Promise { - try { - await callAPI(`/api/projects/${projectId}`, { - method: 'DELETE' - }); - - } catch (error) { - console.error(`Failed to delete project ${projectId}:`, error); - throw error; - } - }, - - /** - * Get features from a project's features JSONB field - */ - async getProjectFeatures(projectId: string): Promise<{ features: any[]; count: number }> { - try { - const response = await callAPI<{ features: any[]; count: number }>(`/api/projects/${projectId}/features`); - return response; - } catch (error) { - console.error(`Failed to get features for project ${projectId}:`, error); - throw error; - } - }, - - // ==================== TASK OPERATIONS ==================== - - /** - * Get all tasks for a project - */ - async getTasksByProject(projectId: string): Promise { - try { - const tasks = await callAPI(`/api/projects/${projectId}/tasks`); - - // Convert database tasks to UI tasks with status mapping - return tasks; - } catch (error) { - console.error(`Failed to get tasks for project ${projectId}:`, error); - throw error; - } - }, - - /** - * Get a specific task by ID - */ - async getTask(taskId: string): Promise { - try { - const task = await callAPI(`/api/tasks/${taskId}`); - return task; - } catch (error) { - console.error(`Failed to get task ${taskId}:`, error); - throw error; - } - }, - - /** - * Create a new task - */ - async createTask(taskData: CreateTaskRequest): Promise { - // Validate input - const validation = validateCreateTask(taskData); - if (!validation.success) { - throw new ValidationError(formatValidationErrors(validation.error)); - } - - try { - // The validation.data already has defaults from schema - const requestData = validation.data; - - const task = await callAPI('/api/tasks', { - method: 'POST', - body: JSON.stringify(requestData) - }); - - - return task; - } catch (error) { - console.error('Failed to create task:', error); - throw error; - } - }, - - /** - * Update an existing task - */ - async updateTask(taskId: string, updates: UpdateTaskRequest): Promise { - // Validate input - const validation = validateUpdateTask(updates); - if (!validation.success) { - throw new ValidationError(formatValidationErrors(validation.error)); - } - - try { - const task = await callAPI(`/api/tasks/${taskId}`, { - method: 'PUT', - body: JSON.stringify(validation.data) - }); - - - return task; - } catch (error) { - console.error(`Failed to update task ${taskId}:`, error); - throw error; - } - }, - - /** - * Update task status (for drag & drop operations) - */ - async updateTaskStatus(taskId: string, status: DatabaseTaskStatus): Promise { - // Validate input - const validation = validateUpdateTaskStatus({ task_id: taskId, status: status }); - if (!validation.success) { - throw new ValidationError(formatValidationErrors(validation.error)); - } - - try { - // Use the standard update task endpoint with JSON body - const task = await callAPI(`/api/tasks/${taskId}`, { - method: 'PUT', - body: JSON.stringify({ status }) - }); - - - return task; - } catch (error) { - console.error(`Failed to update task status ${taskId}:`, error); - throw error; - } - }, - - /** - * Delete a task - */ - async deleteTask(taskId: string): Promise { - try { - // Get task info before deletion for broadcasting - const task = await this.getTask(taskId); - - await callAPI(`/api/tasks/${taskId}`, { - method: 'DELETE' - }); - - } catch (error) { - console.error(`Failed to delete task ${taskId}:`, error); - throw error; - } - }, - - /** - * Update task order for better drag-and-drop support - */ - async updateTaskOrder(taskId: string, newOrder: number, newStatus?: DatabaseTaskStatus): Promise { - try { - const updates: UpdateTaskRequest = { - task_order: newOrder - }; - - if (newStatus) { - updates.status = newStatus; - } - - const task = await this.updateTask(taskId, updates); - - - return task; - } catch (error) { - console.error(`Failed to update task order for ${taskId}:`, error); - throw error; - } - }, - - /** - * Get tasks by status across all projects - */ - async getTasksByStatus(status: DatabaseTaskStatus): Promise { - try { - // Note: This endpoint might need to be implemented in the backend - // For now, we'll get all projects and filter tasks locally - const projects = await this.listProjects(); - const allTasks: Task[] = []; - - for (const project of projects) { - const projectTasks = await this.getTasksByProject(project.id); - // Filter tasks by database status - task.status should be DatabaseTaskStatus from database - allTasks.push(...projectTasks.filter(task => { - return task.status === status; - })); - } - - return allTasks; - } catch (error) { - console.error(`Failed to get tasks by status ${status}:`, error); - throw error; - } - }, - - /** - * Get task counts for all projects in a single batch request - * Optimized endpoint to avoid N+1 query problem - */ - async getTaskCountsForAllProjects(): Promise> { - try { - const response = await callAPI>('/api/projects/task-counts'); - return response || {}; - } catch (error) { - console.error('Failed to get task counts for all projects:', error); - throw error; - } - }, - - - // ==================== DOCUMENT OPERATIONS ==================== - // Note: Documents are stored as JSONB array in project.docs field - // Use getProject() and updateProject() to manage documents - - // ==================== VERSIONING OPERATIONS ==================== - - /** - * Get version history for project documents - */ - async getDocumentVersionHistory(projectId: string, fieldName: string = 'docs'): Promise { - try { - const response = await callAPI<{versions: any[]}>(`/api/projects/${projectId}/versions?field_name=${fieldName}`); - return response.versions || []; - } catch (error) { - console.error(`Failed to get document version history for project ${projectId}:`, error); - throw error; - } - }, - - /** - * Get content of a specific document version for preview - */ - async getVersionContent(projectId: string, versionNumber: number, fieldName: string = 'docs'): Promise { - try { - const response = await callAPI<{content: any, version: any}>(`/api/projects/${projectId}/versions/${fieldName}/${versionNumber}`); - return response; - } catch (error) { - console.error(`Failed to get version ${versionNumber} content for project ${projectId}:`, error); - throw error; - } - }, - - /** - * Restore a project document field to a specific version - */ - async restoreDocumentVersion(projectId: string, versionNumber: number, fieldName: string = 'docs'): Promise { - try { - const response = await callAPI(`/api/projects/${projectId}/versions/${fieldName}/${versionNumber}/restore`, { - method: 'POST' - }); - - - return response; - } catch (error) { - console.error(`Failed to restore version ${versionNumber} for project ${projectId}:`, error); - throw error; - } - }, - - // ==================== UTILITY METHODS ==================== - - /** - * Format relative time for display - */ - formatRelativeTime(dateString: string): string { - const date = new Date(dateString); - const now = new Date(); - const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); - - if (diffInSeconds < 60) return 'just now'; - if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`; - if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`; - if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`; - - return `${Math.floor(diffInSeconds / 604800)} weeks ago`; - } -}; - -// Default export -export default projectService; \ No newline at end of file