diff --git a/src/application/state/state.service.ts b/src/application/state/state.service.ts new file mode 100644 index 0000000..dcad708 --- /dev/null +++ b/src/application/state/state.service.ts @@ -0,0 +1,10 @@ +import type { State } from "../../domain/state/state.entity.js"; +import type { StateRepository } from "../../domain/state/state.repository.js"; + +export class StateService { + constructor(private readonly stateRepo: StateRepository) {} + + async getAll(): Promise { + return this.stateRepo.findAll(); + } +} diff --git a/src/application/task/task.service.ts b/src/application/task/task.service.ts new file mode 100644 index 0000000..d73bea5 --- /dev/null +++ b/src/application/task/task.service.ts @@ -0,0 +1,137 @@ +import type { Task, TaskState } from "../../domain/task/task.entity.js"; +import type { TaskRepository } from "../../domain/task/task.repository.js"; +import type { StateRepository } from "../../domain/state/state.repository.js"; +import type { CreateTaskDto, UpdateTaskDto, AddNoteDto } from "../../shared/dto.js"; +import { AppError } from "../../shared/app-error.js"; + +const VALID_TRANSITIONS: Record = { + new: ["active"], + active: ["resolved", "closed"], + resolved: ["closed", "active"], + closed: [], +}; + +export class TaskService { + constructor( + private readonly taskRepo: TaskRepository, + private readonly stateRepo: StateRepository + ) {} + + async getAll(): Promise { + return this.taskRepo.findAll(); + } + + async getById(id: string): Promise { + const task = await this.taskRepo.findById(id); + if (!task) { + throw new AppError(404, `Task with id '${id}' not found`); + } + return task; + } + + async create(dto: CreateTaskDto): Promise { + const now = new Date().toISOString().split("T")[0]; + + const newTask: Task = { + id: crypto.randomUUID(), + title: dto.title, + description: dto.description, + dueDate: dto.dueDate, + completed: false, + deletedAt: null, + stateHistory: [{ state: "new", date: now }], + notes: dto.notes ?? [], + }; + + return this.taskRepo.create(newTask); + } + + async update(id: string, dto: UpdateTaskDto): Promise { + await this.getById(id); + + const updated = await this.taskRepo.update(id, { + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.dueDate !== undefined && { dueDate: dto.dueDate }), + ...(dto.completed !== undefined && { completed: dto.completed }), + ...(dto.notes !== undefined && { notes: dto.notes }), + }); + + if (!updated) { + throw new AppError(500, "Failed to update task"); + } + + return updated; + } + + async delete(id: string): Promise { + await this.getById(id); + const deleted = await this.taskRepo.delete(id); + if (!deleted) { + throw new AppError(500, "Failed to delete task"); + } + } + + async transitionState(id: string, newState: TaskState): Promise { + const task = await this.getById(id); + const currentState = task.stateHistory[task.stateHistory.length - 1]?.state; + + if (!currentState) { + throw new AppError(500, "Task has no state history"); + } + + const allowed = VALID_TRANSITIONS[currentState]; + if (!allowed.includes(newState)) { + throw new AppError(400, `Cannot transition from '${currentState}' to '${newState}'. Allowed: ${allowed.join(", ") || "none"}`); + } + + const validState = await this.stateRepo.findByName(newState); + if (!validState) { + throw new AppError(400, `Invalid state '${newState}'`); + } + + const now = new Date().toISOString().split("T")[0]; + const updated = await this.taskRepo.update(id, { + stateHistory: [...task.stateHistory, { state: newState, date: now }], + completed: newState === "closed", + }); + + if (!updated) { + throw new AppError(500, "Failed to transition task state"); + } + + return updated; + } + + async addNote(id: string, dto: AddNoteDto): Promise { + const task = await this.getById(id); + + const updated = await this.taskRepo.update(id, { + notes: [...task.notes, dto.note], + }); + + if (!updated) { + throw new AppError(500, "Failed to add note"); + } + + return updated; + } + + async deleteNote(id: string, noteIndex: number): Promise { + const task = await this.getById(id); + + if (noteIndex < 0 || noteIndex >= task.notes.length) { + throw new AppError(400, `Invalid note index ${noteIndex}`); + } + + const notes = [...task.notes]; + notes.splice(noteIndex, 1); + + const updated = await this.taskRepo.update(id, { notes }); + if (!updated) { + throw new AppError(500, "Failed to delete note"); + } + + return updated; + } +}