feat: add presentation layer

- Add Express routes configuration
- Implement TaskController and StateController
- Add error handler and logger middleware
- Add shared DTOs and AppError utility
- Set up application entry point
This commit is contained in:
Andres Fabian Patiño Bermudez 2026-05-14 21:32:59 -05:00
parent f306a2f5ff
commit 61846c97f7
8 changed files with 217 additions and 0 deletions

51
src/index.ts Normal file
View File

@ -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}`);
});

View File

@ -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);
};

View File

@ -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`);
};

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

9
src/shared/app-error.ts Normal file
View File

@ -0,0 +1,9 @@
export class AppError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message);
this.name = "AppError";
}
}

30
src/shared/dto.ts Normal file
View File

@ -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<T> {
success: boolean;
data?: T;
error?: string;
}