import * as fs from "fs"; import * as path from "path"; import { v4 as uuidv4 } from "uuid"; import { logger } from "../utils/logger"; import { query } from "../database/db"; const TODO_DIR = path.join(__dirname, "../../data/todos"); const TODO_FILE = path.join(TODO_DIR, "todos.json"); // 환경 변수로 데이터 소스 선택 (file | database) const DATA_SOURCE = process.env.TODO_DATA_SOURCE || "file"; export interface TodoItem { id: string; title: string; description?: string; priority: "urgent" | "high" | "normal" | "low"; status: "pending" | "in_progress" | "completed"; assignedTo?: string; dueDate?: string; createdAt: string; updatedAt: string; completedAt?: string; isUrgent: boolean; order: number; } export interface TodoListResponse { todos: TodoItem[]; stats: { total: number; pending: number; inProgress: number; completed: number; urgent: number; overdue: number; }; } /** * To-Do 리스트 관리 서비스 (File/DB 하이브리드) */ export class TodoService { private static instance: TodoService; private constructor() { if (DATA_SOURCE === "file") { this.ensureDataDirectory(); } logger.info(`📋 To-Do 데이터 소스: ${DATA_SOURCE.toUpperCase()}`); } public static getInstance(): TodoService { if (!TodoService.instance) { TodoService.instance = new TodoService(); } return TodoService.instance; } /** * 데이터 디렉토리 생성 (파일 모드) */ private ensureDataDirectory(): void { if (!fs.existsSync(TODO_DIR)) { fs.mkdirSync(TODO_DIR, { recursive: true }); logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`); } if (!fs.existsSync(TODO_FILE)) { fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2)); logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`); } } /** * 모든 To-Do 항목 조회 */ public async getAllTodos(filter?: { status?: string; priority?: string; assignedTo?: string; }): Promise { try { const todos = DATA_SOURCE === "database" ? await this.loadTodosFromDB(filter) : this.loadTodosFromFile(filter); // 정렬: 긴급 > 우선순위 > 순서 todos.sort((a, b) => { if (a.isUrgent !== b.isUrgent) return a.isUrgent ? -1 : 1; const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 }; if (a.priority !== b.priority) return priorityOrder[a.priority] - priorityOrder[b.priority]; return a.order - b.order; }); const stats = this.calculateStats(todos); return { todos, stats }; } catch (error) { logger.error("❌ To-Do 목록 조회 오류:", error); throw error; } } /** * To-Do 항목 생성 */ public async createTodo(todoData: Partial): Promise { try { const newTodo: TodoItem = { id: uuidv4(), title: todoData.title || "", description: todoData.description, priority: todoData.priority || "normal", status: "pending", assignedTo: todoData.assignedTo, dueDate: todoData.dueDate, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), isUrgent: todoData.isUrgent || false, order: 0, // DB에서 자동 계산 }; if (DATA_SOURCE === "database") { await this.createTodoDB(newTodo); } else { const todos = this.loadTodosFromFile(); newTodo.order = todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0; todos.push(newTodo); this.saveTodosToFile(todos); } logger.info(`✅ To-Do 생성: ${newTodo.id} - ${newTodo.title}`); return newTodo; } catch (error) { logger.error("❌ To-Do 생성 오류:", error); throw error; } } /** * To-Do 항목 수정 */ public async updateTodo(id: string, updates: Partial): Promise { try { if (DATA_SOURCE === "database") { return await this.updateTodoDB(id, updates); } else { return this.updateTodoFile(id, updates); } } catch (error) { logger.error("❌ To-Do 수정 오류:", error); throw error; } } /** * To-Do 항목 삭제 */ public async deleteTodo(id: string): Promise { try { if (DATA_SOURCE === "database") { await this.deleteTodoDB(id); } else { this.deleteTodoFile(id); } logger.info(`✅ To-Do 삭제: ${id}`); } catch (error) { logger.error("❌ To-Do 삭제 오류:", error); throw error; } } /** * To-Do 항목 순서 변경 */ public async reorderTodos(todoIds: string[]): Promise { try { if (DATA_SOURCE === "database") { await this.reorderTodosDB(todoIds); } else { this.reorderTodosFile(todoIds); } logger.info(`✅ To-Do 순서 변경: ${todoIds.length}개 항목`); } catch (error) { logger.error("❌ To-Do 순서 변경 오류:", error); throw error; } } // ==================== DATABASE 메서드 ==================== private async loadTodosFromDB(filter?: { status?: string; priority?: string; assignedTo?: string; }): Promise { let sql = ` SELECT id, title, description, priority, status, assigned_to as "assignedTo", due_date as "dueDate", created_at as "createdAt", updated_at as "updatedAt", completed_at as "completedAt", is_urgent as "isUrgent", display_order as "order" FROM todo_items WHERE 1=1 `; const params: any[] = []; let paramIndex = 1; if (filter?.status) { sql += ` AND status = $${paramIndex++}`; params.push(filter.status); } if (filter?.priority) { sql += ` AND priority = $${paramIndex++}`; params.push(filter.priority); } if (filter?.assignedTo) { sql += ` AND assigned_to = $${paramIndex++}`; params.push(filter.assignedTo); } sql += ` ORDER BY display_order ASC`; const rows = await query(sql, params); return rows.map((row: any) => ({ ...row, dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, })); } private async createTodoDB(todo: TodoItem): Promise { // 현재 최대 order 값 조회 const maxOrderRows = await query( "SELECT COALESCE(MAX(display_order), -1) + 1 as next_order FROM todo_items" ); const nextOrder = maxOrderRows[0].next_order; await query( `INSERT INTO todo_items ( id, title, description, priority, status, assigned_to, due_date, created_at, updated_at, is_urgent, display_order ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [ todo.id, todo.title, todo.description, todo.priority, todo.status, todo.assignedTo, todo.dueDate ? new Date(todo.dueDate) : null, new Date(todo.createdAt), new Date(todo.updatedAt), todo.isUrgent, nextOrder, ] ); } private async updateTodoDB(id: string, updates: Partial): Promise { const setClauses: string[] = ["updated_at = NOW()"]; const params: any[] = []; let paramIndex = 1; if (updates.title !== undefined) { setClauses.push(`title = $${paramIndex++}`); params.push(updates.title); } if (updates.description !== undefined) { setClauses.push(`description = $${paramIndex++}`); params.push(updates.description); } if (updates.priority !== undefined) { setClauses.push(`priority = $${paramIndex++}`); params.push(updates.priority); } if (updates.status !== undefined) { setClauses.push(`status = $${paramIndex++}`); params.push(updates.status); if (updates.status === "completed") { setClauses.push(`completed_at = NOW()`); } } if (updates.assignedTo !== undefined) { setClauses.push(`assigned_to = $${paramIndex++}`); params.push(updates.assignedTo); } if (updates.dueDate !== undefined) { setClauses.push(`due_date = $${paramIndex++}`); params.push(updates.dueDate ? new Date(updates.dueDate) : null); } if (updates.isUrgent !== undefined) { setClauses.push(`is_urgent = $${paramIndex++}`); params.push(updates.isUrgent); } params.push(id); const sql = ` UPDATE todo_items SET ${setClauses.join(", ")} WHERE id = $${paramIndex} RETURNING id, title, description, priority, status, assigned_to as "assignedTo", due_date as "dueDate", created_at as "createdAt", updated_at as "updatedAt", completed_at as "completedAt", is_urgent as "isUrgent", display_order as "order" `; const rows = await query(sql, params); if (rows.length === 0) { throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); } const row = rows[0]; return { ...row, dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, }; } private async deleteTodoDB(id: string): Promise { const rows = await query("DELETE FROM todo_items WHERE id = $1 RETURNING id", [id]); if (rows.length === 0) { throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); } } private async reorderTodosDB(todoIds: string[]): Promise { for (let i = 0; i < todoIds.length; i++) { await query( "UPDATE todo_items SET display_order = $1, updated_at = NOW() WHERE id = $2", [i, todoIds[i]] ); } } // ==================== FILE 메서드 ==================== private loadTodosFromFile(filter?: { status?: string; priority?: string; assignedTo?: string; }): TodoItem[] { try { const data = fs.readFileSync(TODO_FILE, "utf-8"); let todos: TodoItem[] = JSON.parse(data); if (filter?.status) { todos = todos.filter((t) => t.status === filter.status); } if (filter?.priority) { todos = todos.filter((t) => t.priority === filter.priority); } if (filter?.assignedTo) { todos = todos.filter((t) => t.assignedTo === filter.assignedTo); } return todos; } catch (error) { logger.error("❌ To-Do 파일 로드 오류:", error); return []; } } private saveTodosToFile(todos: TodoItem[]): void { try { fs.writeFileSync(TODO_FILE, JSON.stringify(todos, null, 2)); } catch (error) { logger.error("❌ To-Do 파일 저장 오류:", error); throw error; } } private updateTodoFile(id: string, updates: Partial): TodoItem { const todos = this.loadTodosFromFile(); const index = todos.findIndex((t) => t.id === id); if (index === -1) { throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); } const updatedTodo: TodoItem = { ...todos[index], ...updates, updatedAt: new Date().toISOString(), }; if (updates.status === "completed" && todos[index].status !== "completed") { updatedTodo.completedAt = new Date().toISOString(); } todos[index] = updatedTodo; this.saveTodosToFile(todos); return updatedTodo; } private deleteTodoFile(id: string): void { const todos = this.loadTodosFromFile(); const filteredTodos = todos.filter((t) => t.id !== id); if (todos.length === filteredTodos.length) { throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); } this.saveTodosToFile(filteredTodos); } private reorderTodosFile(todoIds: string[]): void { const todos = this.loadTodosFromFile(); todoIds.forEach((id, index) => { const todo = todos.find((t) => t.id === id); if (todo) { todo.order = index; todo.updatedAt = new Date().toISOString(); } }); this.saveTodosToFile(todos); } // ==================== 공통 메서드 ==================== private calculateStats(todos: TodoItem[]): TodoListResponse["stats"] { const now = new Date(); return { total: todos.length, pending: todos.filter((t) => t.status === "pending").length, inProgress: todos.filter((t) => t.status === "in_progress").length, completed: todos.filter((t) => t.status === "completed").length, urgent: todos.filter((t) => t.isUrgent).length, overdue: todos.filter((t) => t.dueDate && new Date(t.dueDate) < now && t.status !== "completed").length, }; } }