450 lines
13 KiB
TypeScript
450 lines
13 KiB
TypeScript
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<TodoListResponse> {
|
|
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<TodoItem>): Promise<TodoItem> {
|
|
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<TodoItem>): Promise<TodoItem> {
|
|
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<void> {
|
|
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<void> {
|
|
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<TodoItem[]> {
|
|
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<void> {
|
|
// 현재 최대 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<TodoItem>): Promise<TodoItem> {
|
|
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<void> {
|
|
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<void> {
|
|
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>): 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,
|
|
};
|
|
}
|
|
}
|