diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts new file mode 100644 index 0000000..e261d5b --- /dev/null +++ b/src/controllers/AuthController.ts @@ -0,0 +1,38 @@ +import { UserRepository } from "../repositories/UserRepository"; +import { encrypt } from "../utils/encryptionUtils"; +import { ValidationError, UnauthorizedError } from "../errors/httpErrors"; +import { UserDto } from "../dtos/UserDto"; +import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper"; + +export class AuthController { + constructor( + private userRepository: UserRepository, + private mapper: UserDtoEntityMapper + ) {} + + async login(userName: string, password: string): Promise<{ token: string; user: UserDto }> { + if (!userName || !password) { + throw new ValidationError("Username and password are required"); + } + + // Find user by userName + const user = await this.userRepository.findByUserName(userName); + // Compare password + const passwordMatches = encrypt.comparepassword(user.password, password); + if (!passwordMatches || !user ) { + throw new UnauthorizedError("Invalid username or password"); + } + + // Create JWT + const token = encrypt.generateToken({ + id: user.id, + userName: user.userName, + role: user.role, + }); + + return { + token, + user: this.mapper.toDto(user), + }; + } +} diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts new file mode 100644 index 0000000..a00444d --- /dev/null +++ b/src/controllers/UserController.ts @@ -0,0 +1,39 @@ +import { ValidationError, ConflictError } from "../errors/httpErrors"; +import { CreateUserRequestDto } from "../dtos/CreateUserRequestDto"; +import { UserDto } from "../dtos/UserDto"; +import { encrypt } from "../utils/encryptionUtils"; +import { UserRepository } from "../repositories/UserRepository"; +import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper"; +import { isNeitherNullNorEmpty } from "../utils/stringUtils"; + +export class UserController { + constructor( + private userRepository: UserRepository, + private mapper: UserDtoEntityMapper + ) {} + + async createUser(dto: CreateUserRequestDto): Promise { + // check mandatory fields + if (!isNeitherNullNorEmpty(dto.userData.email)) { + throw new ValidationError("Email is required"); + } + if (!isNeitherNullNorEmpty(dto.userData.userName)) { + throw new ValidationError("Username is required"); + } + if(!isNeitherNullNorEmpty(dto.password){ + throw new ValidationError("Password is required"); + } + // user name must be uniqu + const existingUser = await this.userRepository.findByUserName(dto.userData.email); + if (existingUser) { + throw new ConflictError("User with this user name already exists"); + } + + const userEntity = this.mapper.toEntity(dto.userData); + userEntity.password = await encrypt.encryptpass(dto.password); + + const savedUser = await this.userRepository.create(userEntity); + + return this.mapper.toDto(savedUser); + } +} diff --git a/src/dtos/AbstractDto.ts b/src/dtos/AbstractDto.ts index e69de29..a45560d 100644 --- a/src/dtos/AbstractDto.ts +++ b/src/dtos/AbstractDto.ts @@ -0,0 +1,5 @@ +export abstract class AbstractDto { + id: string; + createdAt?: Date; + updatedAt?: Date; +} \ No newline at end of file diff --git a/src/dtos/CreateUserRequestDto.ts b/src/dtos/CreateUserRequestDto.ts new file mode 100644 index 0000000..c0f653d --- /dev/null +++ b/src/dtos/CreateUserRequestDto.ts @@ -0,0 +1,9 @@ +import { UserDto } from "./UserDto"; + +/** + * DTO used for user creation + */ +export class CreateUserRequestDto { + userData: UserDto; + password: string; +} \ No newline at end of file diff --git a/src/dtos/UserDto.ts b/src/dtos/UserDto.ts index cd28649..002fe54 100644 --- a/src/dtos/UserDto.ts +++ b/src/dtos/UserDto.ts @@ -1,8 +1,9 @@ -export class UserDto { - id: string; - firstName: string; - lastName: string; - userName: string; - email: string; - role: string; +import { AbstractDto } from "./AbstractDto"; + +export class UserDto extends AbstractDto { + firstName?: string; + lastName?: string; + userName: string; + email: string; + role: string; } \ No newline at end of file diff --git a/src/endpoints/AuthPoint.ts b/src/endpoints/AuthPoint.ts new file mode 100644 index 0000000..e86bf16 --- /dev/null +++ b/src/endpoints/AuthPoint.ts @@ -0,0 +1,33 @@ +import { Router } from "express"; +import { AuthController } from "../controllers/AuthController"; +import { UserRepository } from "../repositories/UserRepository"; +import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper"; +import { + ValidationError, + UnauthorizedError, + InternalServerError, +} from "../errors/httpErrors"; + +const router = Router(); + +const userRepository = new UserRepository(); +const mapper = new UserDtoEntityMapper(); +const authController = new AuthController(userRepository, mapper); + +router.post("/login", async (req, res) => { + try { + const { userName, password } = req.body; + const result = await authController.login(userName, password); + res.json(result); + } catch (err: any) { + if (err instanceof ValidationError || err instanceof UnauthorizedError) { + res.status(err.statusCode).json({ error: err.message }); + } else { + console.error("Unexpected error:", err); + const internalError = new InternalServerError("Unexpected error"); + res.status(internalError.statusCode).json({ error: internalError.message }); + } + } +}); + +export default router; diff --git a/src/endpoints/authPoint.ts b/src/endpoints/RecipePoint.ts similarity index 100% rename from src/endpoints/authPoint.ts rename to src/endpoints/RecipePoint.ts diff --git a/src/endpoints/UserPoint.ts b/src/endpoints/UserPoint.ts new file mode 100644 index 0000000..7ee533d --- /dev/null +++ b/src/endpoints/UserPoint.ts @@ -0,0 +1,38 @@ +import { Router } from "express"; +import { UserController } from "../controllers/UserController"; +import { CreateUserRequestDto } from "../dtos/CreateUserRequestDto"; +import { UserRepository } from "../repositories/UserRepository"; +import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper"; +import { + ValidationError, + ConflictError, + NotFoundError, + InternalServerError, +} from "../errors/httpErrors"; + +const router = Router(); + +// Inject repo + mapper here +const userRepository = new UserRepository(); +const userMapper = new UserDtoEntityMapper(); +const userController = new UserController(userRepository, userMapper); + +router.post("/", async (req, res) => { + try { + const requestDto: CreateUserRequestDto = req.body; + const responseDto = await userController.createUser(requestDto); + res.status(201).json(responseDto); + } catch (err: any) { + if (err instanceof ValidationError || + err instanceof ConflictError || + err instanceof NotFoundError) { + res.status(err.statusCode).json({ error: err.message }); + } else { + console.error("Unexpected error:", err); + const internalError = new InternalServerError("Some unexpected error occurred!"); + res.status(internalError.statusCode).json({ error: internalError.message }); + } + } +}); + +export default router; diff --git a/src/endpoints/recipePoint.ts b/src/endpoints/recipePoint.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/endpoints/userPoint.ts b/src/endpoints/userPoint.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/entities/AbstractEntity.ts b/src/entities/AbstractEntity.ts index e69de29..1b32897 100644 --- a/src/entities/AbstractEntity.ts +++ b/src/entities/AbstractEntity.ts @@ -0,0 +1,16 @@ +import { + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +export abstract class AbstractEntity { + @PrimaryGeneratedColumn("uuid") + id: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entities/UserEntity.ts b/src/entities/UserEntity.ts index 4a18264..a57d7ac 100644 --- a/src/entities/UserEntity.ts +++ b/src/entities/UserEntity.ts @@ -1,16 +1,8 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, -} from "typeorm"; +import { Entity, Column } from "typeorm"; +import { AbstractEntity } from "./AbstractEntity"; @Entity({ name: "user" }) -export class UserEntity { - @PrimaryGeneratedColumn("uuid") - id: string; - +export class UserEntity extends AbstractEntity { @Column({ nullable: false }) userName: string; @@ -19,19 +11,14 @@ export class UserEntity { @Column({ nullable: false }) password: string; - + @Column({ nullable: true }) firstName: string; - + @Column({ nullable: true }) lastName: string; @Column({ default: "user" }) role: string; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; } + diff --git a/src/errors/httpErrors.ts b/src/errors/httpErrors.ts new file mode 100644 index 0000000..4a34361 --- /dev/null +++ b/src/errors/httpErrors.ts @@ -0,0 +1,39 @@ +export class ValidationError extends Error { + statusCode = 400; + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } +} + +export class NotFoundError extends Error { + statusCode = 404; + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } +} + +export class ConflictError extends Error { + statusCode = 409; + constructor(message: string) { + super(message); + this.name = "ConflictError"; + } +} + +export class InternalServerError extends Error { + statusCode = 500; + constructor(message: string) { + super(message); + this.name = "InternalServerError"; + } +} + +export class UnauthorizedError extends Error { + statusCode = 409; + constructor(message: string) { + super(message); + this.name = "UnauthorizedError"; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 8000104..43d7c5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,9 @@ import { AppDataSource } from "./data-source" import * as express from "express"; import * as dotenv from "dotenv"; import { Request, Response } from "express"; -import { authPoint } from "./endpoints/authPoint"; -import { userPoint } from "./endpoints/userPoint"; -import { recipePoint } from "./endpoints/recipePoint"; +import authRoutes from "./endpoints/AuthPoint" +import userRoutes from "./endpoints/UserPoint"; +//import { recipePoint } from "./endpoints/RecipePoint"; import {errorHandler} from "./middleware/errorHandler" import "reflect-metadata"; dotenv.config(); @@ -16,9 +16,9 @@ app.use(errorHandler); const PORT = process.env.PORT ? parseInt(process.env.PORT) : 4000; const HOST = process.env.HOST || "localhost"; -app.use("/auth", authPoint); -app.use("/user", userPoint); -app.use("/recipe", recipePoint); +app.use("/auth", authRoutes); +app.use("/user", userRoutes); +//app.use("/recipe", recipePoint); app.get("*", (req: Request, res: Response) => { res.status(505).json({ message: "Bad Request" }); diff --git a/src/mappers/AbstractDtoEntityMapper.ts b/src/mappers/AbstractDtoEntityMapper.ts index e69de29..5b76d3b 100644 --- a/src/mappers/AbstractDtoEntityMapper.ts +++ b/src/mappers/AbstractDtoEntityMapper.ts @@ -0,0 +1,31 @@ +import { AbstractDto } from "../dtos/AbstractDto"; +import { AbstractEntity } from "../entities/AbstractEntity"; + +export abstract class AbstractDtoEntityMapper< + E extends AbstractEntity, + D extends AbstractDto +> { + /** + * Map base entity fields (id, createdAt, updatedAt) to DTO. + */ + protected mapBaseEntityToDto(entity: E, dto: D): D { + dto.id = entity.id; + dto.createdAt = entity.createdAt; + dto.updatedAt = entity.updatedAt; + return dto; + } + + /** + * Map base DTO fields to entity. + */ + protected mapBaseDtoToEntity(dto: D, entity: E): E { + entity.id = dto.id; + entity.createdAt = dto.createdAt; + entity.updatedAt = dto.updatedAt; + return entity; + } + + // Abstract methods to be implemented by subclasses + abstract toDto(entity: E): D; + abstract toEntity(dto: D): E; +} diff --git a/src/mappers/UserDtoEntityMapper.ts b/src/mappers/UserDtoEntityMapper.ts index e69de29..7dfc502 100644 --- a/src/mappers/UserDtoEntityMapper.ts +++ b/src/mappers/UserDtoEntityMapper.ts @@ -0,0 +1,32 @@ +import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper"; +import { UserEntity } from "../entities/UserEntity"; +import { UserDto } from "../dtos/UserDto"; + +export class UserDtoEntityMapper extends AbstractDtoEntityMapper { + toDto(entity: UserEntity): UserDto { + const dto = new UserDto(); + this.mapBaseEntityToDto(entity, dto); + + dto.userName = entity.userName; + dto.email = entity.email; + dto.firstName = entity.firstName; + dto.lastName = entity.lastName; + dto.role = entity.role; + + return dto; + } + + toEntity(dto: UserDto): UserEntity { + const entity = new UserEntity(); + this.mapBaseDtoToEntity(dto, entity); + + entity.userName = dto.userName; + entity.email = dto.email; + // ⚠️ Don’t forget password handling — usually set elsewhere, not exposed in DTO + entity.firstName = dto.firstName; + entity.lastName = dto.lastName; + entity.role = dto.role; + + return entity; + } +} diff --git a/src/repositories/AbstractRepository.ts b/src/repositories/AbstractRepository.ts new file mode 100644 index 0000000..d415c6e --- /dev/null +++ b/src/repositories/AbstractRepository.ts @@ -0,0 +1,41 @@ +import { Repository, DeepPartial } from "typeorm"; +import { AppDataSource } from "../data-source"; + +export abstract class AbstractRepository { + protected repo: Repository; + + constructor(entity: { new (): T }) { + this.repo = AppDataSource.getRepository(entity); + } + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } as any }); + } + + async findAll(): Promise { + return this.repo.find(); + } + + async create(data: DeepPartial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + /* async update(id: string, partialData: DeepPartial): Promise { + await this.repo.update(id as any, partialData); + const updated = await this.findById(id); + if (!updated) { + throw new Error("Entity not found after update"); + } + return updated; + } */ + + async delete(id: string): Promise { + await this.repo.delete(id as any); + } + + async save(entity: T): Promise { + return this.repo.save(entity); +} + +} diff --git a/src/repositories/UserRepository.ts b/src/repositories/UserRepository.ts new file mode 100644 index 0000000..392ece5 --- /dev/null +++ b/src/repositories/UserRepository.ts @@ -0,0 +1,12 @@ +import { AbstractRepository } from "./AbstractRepository"; +import { UserEntity } from "../entities/UserEntity"; + +export class UserRepository extends AbstractRepository { + constructor() { + super(UserEntity); + } + + async findByUserName(userName: string): Promise { + return this.repo.findOne({ where: { userName } }); + } +} diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts new file mode 100644 index 0000000..9569793 --- /dev/null +++ b/src/utils/stringUtils.ts @@ -0,0 +1,3 @@ +export function isNeitherNullNorEmpty(input : string) : boolean { + return input && input.length !==0; +} \ No newline at end of file