diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..26d3d8c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,51 @@ +import "dotenv/config"; +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { cors } from "hono/cors"; + +import { prisma } from "./infrastructure/prisma/client.js"; +import { PrismaTaskRepository } from "./infrastructure/task/prisma-task.repository.js"; +import { PrismaStateRepository } from "./infrastructure/state/prisma-state.repository.js"; +import { TaskService } from "./application/task/task.service.js"; +import { StateService } from "./application/state/state.service.js"; +import { createApiRouter } from "./presentation/routes.js"; +import { errorHandler } from "./presentation/middleware/error-handler.js"; +import { logger } from "./presentation/middleware/logger.js"; + +const taskRepository = new PrismaTaskRepository(prisma); +const stateRepository = new PrismaStateRepository(prisma); + +const taskService = new TaskService(taskRepository, stateRepository); +const stateService = new StateService(stateRepository); + +const app = new Hono(); + +const isProduction = process.env.NODE_ENV === "production"; + +app.use( + "*", + cors({ + origin: isProduction ? "https://emi.challenge.berand97.dev" : "*", + }) +); +app.use("*", logger); +app.onError(errorHandler); + +app.route("/api", createApiRouter(taskService, stateService)); + +app.get("/", (c) => { + return c.json({ + message: "Task Management API", + version: "1.0.0", + endpoints: { + tasks: "/api/tasks", + states: "/api/states", + }, + }); +}); + +const port = Number(process.env.PORT) || 3000; + +serve({ fetch: app.fetch, port }, (info) => { + console.log(`Server running on http://localhost:${info.port}`); +}); diff --git a/src/presentation/middleware/error-handler.ts b/src/presentation/middleware/error-handler.ts new file mode 100644 index 0000000..e0b7f84 --- /dev/null +++ b/src/presentation/middleware/error-handler.ts @@ -0,0 +1,11 @@ +import type { ErrorHandler } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +export const errorHandler: ErrorHandler = (err, c) => { + console.error(`[Error]`, err); + + const status = (typeof err === "object" && err !== null && "status" in err ? err.status : 500) as ContentfulStatusCode; + const message = typeof err === "object" && err !== null && "message" in err ? err.message : "Internal server error"; + + return c.json({ success: false, error: message }, status); +}; diff --git a/src/presentation/middleware/logger.ts b/src/presentation/middleware/logger.ts new file mode 100644 index 0000000..271a8ae --- /dev/null +++ b/src/presentation/middleware/logger.ts @@ -0,0 +1,14 @@ +import type { MiddlewareHandler } from "hono"; + +export const logger: MiddlewareHandler = async (c, next) => { + const start = Date.now(); + const method = c.req.method; + const path = c.req.path; + + await next(); + + const duration = Date.now() - start; + const status = c.res.status; + + console.log(`[${new Date().toISOString()}] ${method} ${path} ${status} ${duration}ms`); +}; diff --git a/src/presentation/routes.ts b/src/presentation/routes.ts new file mode 100644 index 0000000..a6ff4db --- /dev/null +++ b/src/presentation/routes.ts @@ -0,0 +1,14 @@ +import { Hono } from "hono"; +import type { TaskService } from "../application/task/task.service.js"; +import type { StateService } from "../application/state/state.service.js"; +import { createTaskController } from "./task/task.controller.js"; +import { createStateController } from "./state/state.controller.js"; + +export function createApiRouter(taskService: TaskService, stateService: StateService): Hono { + const router = new Hono(); + + router.route("/tasks", createTaskController(taskService)); + router.route("/states", createStateController(stateService)); + + return router; +} diff --git a/src/presentation/state/state.controller.ts b/src/presentation/state/state.controller.ts new file mode 100644 index 0000000..51b60d9 --- /dev/null +++ b/src/presentation/state/state.controller.ts @@ -0,0 +1,13 @@ +import { Hono } from "hono"; +import type { StateService } from "../../application/state/state.service.js"; + +export function createStateController(stateService: StateService): Hono { + const controller = new Hono(); + + controller.get("/", async (c) => { + const states = await stateService.getAll(); + return c.json({ success: true, data: states }); + }); + + return controller; +} diff --git a/src/presentation/task/task.controller.ts b/src/presentation/task/task.controller.ts new file mode 100644 index 0000000..e071a90 --- /dev/null +++ b/src/presentation/task/task.controller.ts @@ -0,0 +1,75 @@ +import { Hono } from "hono"; +import type { TaskService } from "../../application/task/task.service.js"; +import type { CreateTaskDto, UpdateTaskDto, TransitionStateDto, AddNoteDto } from "../../shared/dto.js"; + +export function createTaskController(taskService: TaskService): Hono { + const controller = new Hono(); + + controller.get("/", async (c) => { + const tasks = await taskService.getAll(); + return c.json({ success: true, data: tasks }); + }); + + controller.get("/:id", async (c) => { + const id = c.req.param("id"); + const task = await taskService.getById(id); + return c.json({ success: true, data: task }); + }); + + controller.post("/", async (c) => { + const body = (await c.req.json()) as CreateTaskDto; + + if (!body.title || !body.description || !body.dueDate) { + return c.json({ success: false, error: "title, description and dueDate are required" }, 400); + } + + const task = await taskService.create(body); + return c.json({ success: true, data: task }, 201); + }); + + controller.put("/:id", async (c) => { + const id = c.req.param("id"); + const body = (await c.req.json()) as UpdateTaskDto; + const task = await taskService.update(id, body); + return c.json({ success: true, data: task }); + }); + + controller.delete("/:id", async (c) => { + const id = c.req.param("id"); + await taskService.delete(id); + return c.json({ success: true, data: null }); + }); + + controller.patch("/:id/transition", async (c) => { + const id = c.req.param("id"); + const body = (await c.req.json()) as TransitionStateDto; + + if (!body.state) { + return c.json({ success: false, error: "state is required" }, 400); + } + + const task = await taskService.transitionState(id, body.state); + return c.json({ success: true, data: task }); + }); + + controller.post("/:id/notes", async (c) => { + const id = c.req.param("id"); + const body = (await c.req.json()) as AddNoteDto; + + if (!body.note) { + return c.json({ success: false, error: "note is required" }, 400); + } + + const task = await taskService.addNote(id, body); + return c.json({ success: true, data: task }); + }); + + controller.delete("/:id/notes/:noteIndex", async (c) => { + const id = c.req.param("id"); + const noteIndex = parseInt(c.req.param("noteIndex"), 10); + const task = await taskService.deleteNote(id, noteIndex); + return c.json({ success: true, data: task }); + }); + + return controller; +} diff --git a/src/shared/app-error.ts b/src/shared/app-error.ts new file mode 100644 index 0000000..b863baa --- /dev/null +++ b/src/shared/app-error.ts @@ -0,0 +1,9 @@ +export class AppError extends Error { + constructor( + public readonly status: number, + message: string + ) { + super(message); + this.name = "AppError"; + } +} diff --git a/src/shared/dto.ts b/src/shared/dto.ts new file mode 100644 index 0000000..c87e1ee --- /dev/null +++ b/src/shared/dto.ts @@ -0,0 +1,30 @@ +import type { TaskState } from "../domain/task/task.entity.js"; + +export interface CreateTaskDto { + title: string; + description: string; + dueDate: string; + notes?: string[]; +} + +export interface UpdateTaskDto { + title?: string; + description?: string; + dueDate?: string; + completed?: boolean; + notes?: string[]; +} + +export interface TransitionStateDto { + state: TaskState; +} + +export interface AddNoteDto { + note: string; +} + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; +}