ERP-node/backend-node/src/services/todoService.ts

476 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 {
try {
if (!fs.existsSync(TODO_DIR)) {
fs.mkdirSync(TODO_DIR, { recursive: true, mode: 0o755 });
logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`);
}
if (!fs.existsSync(TODO_FILE)) {
fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2), {
mode: 0o644,
});
logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`);
}
} catch (error) {
logger.error(`❌ To-Do 디렉토리 생성 실패: ${TODO_DIR}`, error);
throw error;
}
}
/**
* 모든 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,
};
}
}