diff --git a/bruno/recipe-backend/UserPoint/changePassword.bru b/bruno/recipe-backend/UserPoint/changePassword.bru new file mode 100644 index 0000000..7fc94a5 --- /dev/null +++ b/bruno/recipe-backend/UserPoint/changePassword.bru @@ -0,0 +1,22 @@ +meta { + name: changePassword + type: http + seq: 8 +} + +post { + url: {{url}}/user/change-password + body: json + auth: inherit +} + +body:json { + { + "userId": "9c913747-ba57-4b12-87d0-3339f4a8117c", + "password": "test" + } +} + +settings { + encodeUrl: true +} diff --git a/bruno/recipe-backend/collection.bru b/bruno/recipe-backend/collection.bru new file mode 100644 index 0000000..04196f0 --- /dev/null +++ b/bruno/recipe-backend/collection.bru @@ -0,0 +1,25 @@ +auth { + mode: bearer +} + +auth:bearer { + token: {{token}} +} + +script:pre-request { + try{ + // An dieser Stelle muss überprüft werden, ob diese Funktion gerade aufgerufen wird, ansonsten entsteht eine Endlosschleife. + const blocked = bru.getEnvVar("blocked"); + if(blocked === "false" && new Date().valueOf() > Number(bru.getEnvVar("tokenExpireDate"))){ + console.log('new Session') + + bru.setEnvVar("blocked",true) + // Absoluter Pfad von der Collection-Root + await bru.runRequest('AuthPoint/login.bru') + + bru.setEnvVar("blocked",false) + } + } catch (e){ + console.log(e) + } +} diff --git a/src/apiHelpers/HttpStatusCodes.ts b/src/api/apiHelpers/HttpStatusCodes.ts similarity index 100% rename from src/apiHelpers/HttpStatusCodes.ts rename to src/api/apiHelpers/HttpStatusCodes.ts diff --git a/src/dtos/AbstractDto.ts b/src/api/dtos/AbstractDto.ts similarity index 100% rename from src/dtos/AbstractDto.ts rename to src/api/dtos/AbstractDto.ts diff --git a/src/dtos/AuthPayload.ts b/src/api/dtos/AuthPayload.ts similarity index 100% rename from src/dtos/AuthPayload.ts rename to src/api/dtos/AuthPayload.ts diff --git a/src/dtos/ChangeUserPasswordRequest.ts b/src/api/dtos/ChangeUserPasswordRequest.ts similarity index 100% rename from src/dtos/ChangeUserPasswordRequest.ts rename to src/api/dtos/ChangeUserPasswordRequest.ts diff --git a/src/dtos/CompactRecipeDto.ts b/src/api/dtos/CompactRecipeDto.ts similarity index 100% rename from src/dtos/CompactRecipeDto.ts rename to src/api/dtos/CompactRecipeDto.ts diff --git a/src/dtos/CreateUserRequest.ts b/src/api/dtos/CreateUserRequest.ts similarity index 100% rename from src/dtos/CreateUserRequest.ts rename to src/api/dtos/CreateUserRequest.ts diff --git a/src/dtos/CreateUserResponse.ts b/src/api/dtos/CreateUserResponse.ts similarity index 100% rename from src/dtos/CreateUserResponse.ts rename to src/api/dtos/CreateUserResponse.ts diff --git a/src/dtos/LoginRequest.ts b/src/api/dtos/LoginRequest.ts similarity index 100% rename from src/dtos/LoginRequest.ts rename to src/api/dtos/LoginRequest.ts diff --git a/src/dtos/LoginResponse.ts b/src/api/dtos/LoginResponse.ts similarity index 100% rename from src/dtos/LoginResponse.ts rename to src/api/dtos/LoginResponse.ts diff --git a/src/dtos/RecipeDto.ts b/src/api/dtos/RecipeDto.ts similarity index 100% rename from src/dtos/RecipeDto.ts rename to src/api/dtos/RecipeDto.ts diff --git a/src/dtos/RecipeIngredientDto.ts b/src/api/dtos/RecipeIngredientDto.ts similarity index 100% rename from src/dtos/RecipeIngredientDto.ts rename to src/api/dtos/RecipeIngredientDto.ts diff --git a/src/dtos/RecipeIngredientGroupDto.ts b/src/api/dtos/RecipeIngredientGroupDto.ts similarity index 100% rename from src/dtos/RecipeIngredientGroupDto.ts rename to src/api/dtos/RecipeIngredientGroupDto.ts diff --git a/src/api/dtos/TagDto.ts b/src/api/dtos/TagDto.ts new file mode 100644 index 0000000..99d7775 --- /dev/null +++ b/src/api/dtos/TagDto.ts @@ -0,0 +1,8 @@ +import {AbstractDto} from "./AbstractDto.js"; + +/** + * DTO describing a tag + */ +export class TagDto extends AbstractDto { + description!: string; +} \ No newline at end of file diff --git a/src/dtos/UserDto.ts b/src/api/dtos/UserDto.ts similarity index 100% rename from src/dtos/UserDto.ts rename to src/api/dtos/UserDto.ts diff --git a/src/dtos/UserListResponse.ts b/src/api/dtos/UserListResponse.ts similarity index 100% rename from src/dtos/UserListResponse.ts rename to src/api/dtos/UserListResponse.ts diff --git a/src/endpoints/AuthRestResource.ts b/src/api/endpoints/AuthRestResource.ts similarity index 84% rename from src/endpoints/AuthRestResource.ts rename to src/api/endpoints/AuthRestResource.ts index 8ee84d0..da7f1dd 100644 --- a/src/endpoints/AuthRestResource.ts +++ b/src/api/endpoints/AuthRestResource.ts @@ -1,7 +1,7 @@ import { Router } from "express"; -import { AuthHandler } from "../handlers/AuthHandler.js"; -import { UserRepository } from "../repositories/UserRepository.js"; -import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; +import { AuthHandler } from "../../handlers/AuthHandler.js"; +import { UserRepository } from "../../repositories/UserRepository.js"; +import { UserDtoEntityMapper } from "../../mappers/UserDtoEntityMapper.js"; import { ValidationError, UnauthorizedError, diff --git a/src/endpoints/CompactRecipeRestResource.ts b/src/api/endpoints/CompactRecipeRestResource.ts similarity index 74% rename from src/endpoints/CompactRecipeRestResource.ts rename to src/api/endpoints/CompactRecipeRestResource.ts index 8a822e9..0dfa735 100644 --- a/src/endpoints/CompactRecipeRestResource.ts +++ b/src/api/endpoints/CompactRecipeRestResource.ts @@ -1,8 +1,8 @@ import { Router } from "express"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import { RecipeRepository } from "../repositories/RecipeRepository.js"; -import { CompactRecipeHandler } from "../handlers/CompactRecipeHandler.js"; -import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js"; +import { asyncHandler } from "../../utils/asyncHandler.js"; +import { RecipeRepository } from "../../repositories/RecipeRepository.js"; +import { CompactRecipeHandler } from "../../handlers/CompactRecipeHandler.js"; +import { CompactRecipeDtoEntityMapper } from "../../mappers/CompactRecipeDtoEntityMapper.js"; import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; /** diff --git a/src/endpoints/RecipeRestResource.ts b/src/api/endpoints/RecipeRestResource.ts similarity index 71% rename from src/endpoints/RecipeRestResource.ts rename to src/api/endpoints/RecipeRestResource.ts index a6a3886..f441feb 100644 --- a/src/endpoints/RecipeRestResource.ts +++ b/src/api/endpoints/RecipeRestResource.ts @@ -1,12 +1,12 @@ import { Router } from "express"; -import { RecipeRepository } from "../repositories/RecipeRepository.js"; -import { RecipeDtoEntityMapper } from "../mappers/RecipeDtoEntityMapper.js"; -import { RecipeHandler } from "../handlers/RecipeHandler.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; +import { RecipeRepository } from "../../repositories/RecipeRepository.js"; +import { RecipeDtoEntityMapper } from "../../mappers/RecipeDtoEntityMapper.js"; +import { RecipeHandler } from "../../handlers/RecipeHandler.js"; +import { asyncHandler } from "../../utils/asyncHandler.js"; import { RecipeDto } from "../dtos/RecipeDto.js"; -import { RecipeIngredientDtoEntityMapper } from "../mappers/RecipeIngredientDtoEntityMapper.js"; -import { RecipeIngredientGroupDtoEntityMapper } from "../mappers/RecipeIngredientGroupDtoEntityMapper.js"; -import { RecipeInstructionStepDtoEntityMapper } from "../mappers/RecipeInstructionStepDtoEntityMapper.js"; +import { RecipeIngredientDtoEntityMapper } from "../../mappers/RecipeIngredientDtoEntityMapper.js"; +import { RecipeIngredientGroupDtoEntityMapper } from "../../mappers/RecipeIngredientGroupDtoEntityMapper.js"; +import { RecipeInstructionStepDtoEntityMapper } from "../../mappers/RecipeInstructionStepDtoEntityMapper.js"; import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; /** diff --git a/src/api/endpoints/TagRestResource.ts b/src/api/endpoints/TagRestResource.ts new file mode 100644 index 0000000..4dfa4e9 --- /dev/null +++ b/src/api/endpoints/TagRestResource.ts @@ -0,0 +1,72 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { TagHandler } from "../../handlers/TagHandler.js"; +import { TagDto } from "../dtos/TagDto.js"; +import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; +import {requireAdmin} from "../../middleware/authorizationMiddleware.js"; + +/** + * REST resource for tags. + * + * Routes: + * GET /tags — list all tags (authenticated users) + * POST /tags/create-or-update — create or update a tag (authenticated users) + * DELETE /tags/:id — delete a tag (administrators only) + * + * Authentication / authorisation is assumed to be enforced by middleware + * applied upstream (e.g. a global JWT guard). The adminOnly middleware below + * adds the role check that restricts DELETE to administrators. + */ + +const router = Router(); +const tagHandler = new TagHandler(); + +/** + * GET /tags + * Returns all available tags. + */ +router.get("/", async (_req: Request, res: Response, next: NextFunction) => { + try { + const tags = await tagHandler.getAll(); + res.json(tags); + } catch (err) { + next(err); + } +}); + +/** + * POST /tags/create-or-update + * Creates a new tag or updates an existing one. + * Body: TagDto + */ +router.post( + "/create-or-update", + async (req: Request, res: Response, next: NextFunction) => { + try { + const dto: TagDto = req.body; + const result = await tagHandler.createOrUpdate(dto); + res.status(HttpStatusCode.CREATED).json(result); + } catch (err) { + next(err); + } + } +); + +/** + * DELETE /tags/:id + * Deletes a tag by ID. Restricted to administrators. + * The database cascade removes all entries in the recipe_tag mapping table. + */ +router.delete( + "/:id", + requireAdmin, + async (req: Request, res: Response, next: NextFunction) => { + try { + await tagHandler.delete(req.params.id); + res.status(HttpStatusCode.NO_CONTENT).send(); + } catch (err) { + next(err); + } + } +); + +export default router; \ No newline at end of file diff --git a/src/endpoints/UserRestResource.ts b/src/api/endpoints/UserRestResource.ts similarity index 91% rename from src/endpoints/UserRestResource.ts rename to src/api/endpoints/UserRestResource.ts index 2f1abb4..01dd84a 100644 --- a/src/endpoints/UserRestResource.ts +++ b/src/api/endpoints/UserRestResource.ts @@ -1,16 +1,16 @@ import { Router } from "express"; -import { UserHandler } from "../handlers/UserHandler.js"; +import { UserHandler } from "../../handlers/UserHandler.js"; import { CreateUserRequest } from "../dtos/CreateUserRequest.js"; -import { UserRepository } from "../repositories/UserRepository.js"; -import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; +import { UserRepository } from "../../repositories/UserRepository.js"; +import { UserDtoEntityMapper } from "../../mappers/UserDtoEntityMapper.js"; +import { asyncHandler } from "../../utils/asyncHandler.js"; import { CreateUserResponse } from "../dtos/CreateUserResponse.js"; import { UserDto } from "../dtos/UserDto.js"; import { requireAdmin, requireAdminOrOwner, requireAdminOrSelf -} from "../middleware/authorizationMiddleware.js"; +} from "../../middleware/authorizationMiddleware.js"; import { UserListResponse } from "../dtos/UserListResponse.js"; import { HttpStatusCode } from "../apiHelpers/HttpStatusCodes.js"; import { InternalServerError, NotFoundError } from "../errors/httpErrors.js"; diff --git a/src/enums/UserRole.ts b/src/api/enums/UserRole.ts similarity index 100% rename from src/enums/UserRole.ts rename to src/api/enums/UserRole.ts diff --git a/src/errors/httpErrors.ts b/src/api/errors/httpErrors.ts similarity index 100% rename from src/errors/httpErrors.ts rename to src/api/errors/httpErrors.ts diff --git a/src/entities/RecipeEntity.ts b/src/entities/RecipeEntity.ts index 2f3b8fe..c946707 100644 --- a/src/entities/RecipeEntity.ts +++ b/src/entities/RecipeEntity.ts @@ -1,7 +1,8 @@ -import { Entity, Column, OneToMany, Relation } from "typeorm"; +import { Entity, Column, OneToMany, ManyToMany, JoinTable, Relation } from "typeorm"; import { AbstractEntity } from "./AbstractEntity.js"; import { RecipeInstructionStepEntity } from "./RecipeInstructionStepEntity.js"; import { RecipeIngredientGroupEntity } from "./RecipeIngredientGroupEntity.js"; +import { TagEntity } from "./TagEntity.js"; /** * Entity describing a recipe @@ -10,22 +11,46 @@ import { RecipeIngredientGroupEntity } from "./RecipeIngredientGroupEntity.js"; export class RecipeEntity extends AbstractEntity { @Column({ nullable: false }) title!: string; - + @Column({ nullable: true }) amount?: number; - + @Column({ nullable: true, name: "amount_description" }) amountDescription?: string; - // make sure not to induce a circular dependency! user arrow function without brackets! - @OneToMany(() => RecipeInstructionStepEntity, (instructionStep) => instructionStep.recipe, { + // make sure not to induce a circular dependency! use arrow function without brackets! + @OneToMany(() => RecipeInstructionStepEntity, (instructionStep) => instructionStep.recipe, { cascade: true }) instructionSteps!: RecipeInstructionStepEntity[]; - // make sure not to induce a circular dependency! user arrow function without brackets! - @OneToMany(() => RecipeIngredientGroupEntity, (ingredientGroup) => ingredientGroup.recipe, { + // make sure not to induce a circular dependency! use arrow function without brackets! + @OneToMany(() => RecipeIngredientGroupEntity, (ingredientGroup) => ingredientGroup.recipe, { cascade: true }) ingredientGroups!: Relation[]; + + /** + * Tags associated with this recipe. + * + * RecipeEntity is the *owning* side of the relation: + * - @JoinTable creates and owns the `recipe_tag` mapping table. + * - cascade insert/update allows tags to be persisted via recipe save. + * + * Deleting a Recipe removes its rows from recipe_tag automatically via the + * ON DELETE CASCADE FK defined in the migration. Tags themselves are unaffected. + * Deleting a Tag similarly removes its rows from recipe_tag via the other FK. + * + * make sure not to induce a circular dependency! use arrow function without brackets! + */ + @ManyToMany(() => TagEntity, (tag) => tag.recipeList, { + cascade: ["insert", "update"], + eager: false, + }) + @JoinTable({ + name: "recipe_tag", + joinColumn: { name: "recipe_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "tag_id", referencedColumnName: "id" }, + }) + tagList!: TagEntity[]; } \ No newline at end of file diff --git a/src/entities/TagEntity.ts b/src/entities/TagEntity.ts new file mode 100644 index 0000000..e50532b --- /dev/null +++ b/src/entities/TagEntity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, ManyToMany } from "typeorm"; +import { AbstractEntity } from "./AbstractEntity.js"; +import { RecipeEntity } from "./RecipeEntity.js"; + +/** + * Persisted tag that can be attached to any number of recipes. + * The join table (recipe_tag) is owned by RecipeEntity. + */ +@Entity({ name: "tag" }) +export class TagEntity extends AbstractEntity { + @Column({ type: "varchar", length: 100, unique: true }) + description!: string; + + /** + * Inverse side of the ManyToMany relation. + * The join table is declared on RecipeEntity to keep ownership there. + */ + @ManyToMany(() => RecipeEntity, recipe => recipe.tagList) + recipeList!: RecipeEntity[]; +} \ No newline at end of file diff --git a/src/entities/UserEntity.ts b/src/entities/UserEntity.ts index 1415302..621e926 100644 --- a/src/entities/UserEntity.ts +++ b/src/entities/UserEntity.ts @@ -1,6 +1,6 @@ import { Entity, Column } from "typeorm"; import { AbstractEntity } from "./AbstractEntity.js"; -import { UserRole } from "../enums/UserRole.js"; +import { UserRole } from "../api/enums/UserRole.js"; /** * Entity describing a user diff --git a/src/handlers/AuthHandler.ts b/src/handlers/AuthHandler.ts index 4f2bffa..64b585e 100644 --- a/src/handlers/AuthHandler.ts +++ b/src/handlers/AuthHandler.ts @@ -1,9 +1,9 @@ import { UserRepository } from "../repositories/UserRepository.js"; import { encrypt } from "../utils/encryptionUtils.js"; -import { ValidationError, UnauthorizedError } from "../errors/httpErrors.js"; +import { ValidationError, UnauthorizedError } from "../api/errors/httpErrors.js"; import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; -import { LoginResponse } from "../dtos/LoginResponse.js"; -import { LoginRequest } from "../dtos/LoginRequest.js"; +import { LoginResponse } from "../api/dtos/LoginResponse.js"; +import { LoginRequest } from "../api/dtos/LoginRequest.js"; /** * Controller responsible for authentication, e.g., login or issueing a token with extended diff --git a/src/handlers/CompactRecipeHandler.ts b/src/handlers/CompactRecipeHandler.ts index 61482cd..f83a490 100644 --- a/src/handlers/CompactRecipeHandler.ts +++ b/src/handlers/CompactRecipeHandler.ts @@ -1,4 +1,4 @@ -import { CompactRecipeDto } from "../dtos/CompactRecipeDto.js"; +import { CompactRecipeDto } from "../api/dtos/CompactRecipeDto.js"; import { RecipeEntity } from "../entities/RecipeEntity.js"; import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js"; import { RecipeRepository } from "../repositories/RecipeRepository.js"; diff --git a/src/handlers/RecipeHandler.ts b/src/handlers/RecipeHandler.ts index 4aef98e..e90e951 100644 --- a/src/handlers/RecipeHandler.ts +++ b/src/handlers/RecipeHandler.ts @@ -1,10 +1,10 @@ -import { RecipeDto } from "../dtos/RecipeDto.js"; +import { RecipeDto } from "../api/dtos/RecipeDto.js"; import { RecipeDtoEntityMapper } from "../mappers/RecipeDtoEntityMapper.js"; import { RecipeRepository } from "../repositories/RecipeRepository.js"; -import { NotFoundError, ValidationError } from "../errors/httpErrors.js"; -import { RecipeInstructionStepDto } from "../dtos/RecipeInstructionStepDto.js"; -import { RecipeIngredientGroupDto } from "../dtos/RecipeIngredientGroupDto.js"; -import { RecipeIngredientDto } from "../dtos/RecipeIngredientDto.js"; +import { NotFoundError, ValidationError } from "../api/errors/httpErrors.js"; +import { RecipeInstructionStepDto } from "../api/dtos/RecipeInstructionStepDto.js"; +import { RecipeIngredientGroupDto } from "../api/dtos/RecipeIngredientGroupDto.js"; +import { RecipeIngredientDto } from "../api/dtos/RecipeIngredientDto.js"; import { RecipeEntity } from "../entities/RecipeEntity.js"; /** @@ -62,7 +62,7 @@ export class RecipeHandler { } /** * Update recipe data - * @param RecipeDto containing the entire updated recipe + * @param dto containing the entire updated recipe * @returns Up-to-date RecipeDto as saved in the database */ async updateRecipe(dto: RecipeDto){ diff --git a/src/handlers/TagHandler.ts b/src/handlers/TagHandler.ts new file mode 100644 index 0000000..1bcaca2 --- /dev/null +++ b/src/handlers/TagHandler.ts @@ -0,0 +1,73 @@ +import { TagRepository } from "../repositories/TagRepository.js"; +import { TagDtoEntityMapper } from "../mappers/TagDtoEntityMapper.js"; +import { TagDto } from "../api/dtos/TagDto.js"; + +/** + * Handles business logic for tag operations. + * Called by TagRestResource; keeps routing and business logic separated. + */ +export class TagHandler { + private readonly tagRepository: TagRepository; + private readonly tagMapper: TagDtoEntityMapper; + + constructor() { + this.tagRepository = new TagRepository(); + this.tagMapper = new TagDtoEntityMapper(); + } + + /** + * Returns all tags. + */ + async getAll(): Promise { + const entities = await this.tagRepository.findAll(); + return entities.map(e => this.tagMapper.toDto(e)); + } + + /** + * Creates a new tag or updates an existing one. + * + * - If a tag with the same description already exists (case-insensitive) + * the existing tag is returned unchanged (idempotent behaviour matching + * the "create-or-update" contract). + * - If a DTO with an explicit ID is supplied, the description of that tag + * is updated. + * - Otherwise a brand-new tag is created. + */ + async createOrUpdate(dto: TagDto): Promise { + // Update path: ID supplied — find and merge changes into existing entity + if (dto.id) { + const existing = await this.tagRepository.findById(dto.id); + if (!existing) { + throw new Error(`Tag with id ${dto.id} not found`); + } + const updated = await this.tagRepository.update( + this.tagMapper.mergeDtoIntoEntity(dto, existing) + ); + return this.tagMapper.toDto(updated); + } + + // Create path: check for duplicate description first + const duplicate = await this.tagRepository.findByDescription(dto.description); + if (duplicate) { + return this.tagMapper.toDto(duplicate); + } + + const created = await this.tagRepository.create(this.tagMapper.toEntity(dto)); + return this.tagMapper.toDto(created); + } + + /** + * Deletes a tag by ID. + * The ON DELETE CASCADE on the recipe_tag join table ensures all + * references are cleaned up automatically by the database. + * + * @throws Error when the tag does not exist. + */ + async delete(id: string): Promise { + const existing = await this.tagRepository.findById(id); + if (!existing) { + throw new Error(`Tag with id ${id} not found`); + } + await this.tagRepository.delete(id); + } +} \ No newline at end of file diff --git a/src/handlers/UserHandler.ts b/src/handlers/UserHandler.ts index e51776e..e7e674e 100644 --- a/src/handlers/UserHandler.ts +++ b/src/handlers/UserHandler.ts @@ -1,13 +1,13 @@ -import {ConflictError, NotFoundError, ValidationError} from "../errors/httpErrors.js"; -import {CreateUserRequest} from "../dtos/CreateUserRequest.js"; -import {UserDto} from "../dtos/UserDto.js"; +import {ConflictError, NotFoundError, ValidationError} from "../api/errors/httpErrors.js"; +import {CreateUserRequest} from "../api/dtos/CreateUserRequest.js"; +import {UserDto} from "../api/dtos/UserDto.js"; import {encrypt} from "../utils/encryptionUtils.js"; import {UserRepository} from "../repositories/UserRepository.js"; import {UserDtoEntityMapper} from "../mappers/UserDtoEntityMapper.js"; import {UUID} from "crypto"; -import {ChangeUserPasswordRequest} from "../dtos/ChangeUserPasswordRequest.js"; +import {ChangeUserPasswordRequest} from "../api/dtos/ChangeUserPasswordRequest.js"; import {UserEntity} from "../entities/UserEntity.js"; -import {UserRole, UserRoleHelper} from "../enums/UserRole.js"; +import {UserRole, UserRoleHelper} from "../api/enums/UserRole.js"; /** * Controls all user specific actions diff --git a/src/index.ts b/src/index.ts index 7c917f4..de20338 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,11 +6,11 @@ import { requestLoggerMiddleware } from "./middleware/requestLoggerMiddleware.js import { errorLoggerMiddleware } from "./middleware/errorLoggerMiddleware.js"; import { corsHeaders } from "./middleware/corsMiddleware.js"; import { logger } from "./utils/logger.js"; -import { HttpStatusCode } from "./apiHelpers/HttpStatusCodes.js"; -import authRoutes, { authBasicRoute } from "./endpoints/AuthRestResource.js"; -import userRoutes, { userBasicRoute } from "./endpoints/UserRestResource.js"; -import compactRecipeRoutes from "./endpoints/CompactRecipeRestResource.js"; -import recipeRoutes from "./endpoints/RecipeRestResource.js"; +import { HttpStatusCode } from "./api/apiHelpers/HttpStatusCodes.js"; +import authRoutes, { authBasicRoute } from "./api/endpoints/AuthRestResource.js"; +import userRoutes, { userBasicRoute } from "./api/endpoints/UserRestResource.js"; +import compactRecipeRoutes from "./api/endpoints/CompactRecipeRestResource.js"; +import recipeRoutes from "./api/endpoints/RecipeRestResource.js"; dotenv.config(); diff --git a/src/mappers/AbstractDtoEntityMapper.ts b/src/mappers/AbstractDtoEntityMapper.ts index dd878bc..935c341 100644 --- a/src/mappers/AbstractDtoEntityMapper.ts +++ b/src/mappers/AbstractDtoEntityMapper.ts @@ -1,4 +1,4 @@ -import { AbstractDto } from "../dtos/AbstractDto.js"; +import { AbstractDto } from "../api/dtos/AbstractDto.js"; import { AbstractEntity } from "../entities/AbstractEntity.js"; export abstract class AbstractDtoEntityMapper< diff --git a/src/mappers/CompactRecipeDtoEntityMapper.ts b/src/mappers/CompactRecipeDtoEntityMapper.ts index b8040a3..3578df8 100644 --- a/src/mappers/CompactRecipeDtoEntityMapper.ts +++ b/src/mappers/CompactRecipeDtoEntityMapper.ts @@ -1,4 +1,4 @@ -import { CompactRecipeDto } from "../dtos/CompactRecipeDto.js"; +import { CompactRecipeDto } from "../api/dtos/CompactRecipeDto.js"; import { RecipeEntity } from "../entities/RecipeEntity.js"; import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js"; diff --git a/src/mappers/RecipeDtoEntityMapper.ts b/src/mappers/RecipeDtoEntityMapper.ts index ee97da9..f5bb591 100644 --- a/src/mappers/RecipeDtoEntityMapper.ts +++ b/src/mappers/RecipeDtoEntityMapper.ts @@ -1,6 +1,6 @@ -import { RecipeDto } from "../dtos/RecipeDto.js"; -import { RecipeIngredientGroupDto } from "../dtos/RecipeIngredientGroupDto.js"; -import { RecipeInstructionStepDto } from "../dtos/RecipeInstructionStepDto.js"; +import { RecipeDto } from "../api/dtos/RecipeDto.js"; +import { RecipeIngredientGroupDto } from "../api/dtos/RecipeIngredientGroupDto.js"; +import { RecipeInstructionStepDto } from "../api/dtos/RecipeInstructionStepDto.js"; import { RecipeEntity } from "../entities/RecipeEntity.js"; import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js"; import { RecipeIngredientGroupDtoEntityMapper } from "./RecipeIngredientGroupDtoEntityMapper.js"; diff --git a/src/mappers/RecipeIngredientDtoEntityMapper.ts b/src/mappers/RecipeIngredientDtoEntityMapper.ts index fb1f6cd..be7a35a 100644 --- a/src/mappers/RecipeIngredientDtoEntityMapper.ts +++ b/src/mappers/RecipeIngredientDtoEntityMapper.ts @@ -1,4 +1,4 @@ -import { RecipeIngredientDto } from "../dtos/RecipeIngredientDto.js"; +import { RecipeIngredientDto } from "../api/dtos/RecipeIngredientDto.js"; import { RecipeIngredientEntity } from "../entities/RecipeIngredientEntity.js"; import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js"; diff --git a/src/mappers/RecipeIngredientGroupDtoEntityMapper.ts b/src/mappers/RecipeIngredientGroupDtoEntityMapper.ts index 3002695..966143f 100644 --- a/src/mappers/RecipeIngredientGroupDtoEntityMapper.ts +++ b/src/mappers/RecipeIngredientGroupDtoEntityMapper.ts @@ -1,5 +1,5 @@ -import { RecipeIngredientDto } from "../dtos/RecipeIngredientDto.js"; -import { RecipeIngredientGroupDto } from "../dtos/RecipeIngredientGroupDto.js"; +import { RecipeIngredientDto } from "../api/dtos/RecipeIngredientDto.js"; +import { RecipeIngredientGroupDto } from "../api/dtos/RecipeIngredientGroupDto.js"; import { RecipeIngredientGroupEntity } from "../entities/RecipeIngredientGroupEntity.js"; import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js"; import { RecipeIngredientDtoEntityMapper } from "./RecipeIngredientDtoEntityMapper.js"; diff --git a/src/mappers/RecipeInstructionStepDtoEntityMapper.ts b/src/mappers/RecipeInstructionStepDtoEntityMapper.ts index 14f02ff..884e8bd 100644 --- a/src/mappers/RecipeInstructionStepDtoEntityMapper.ts +++ b/src/mappers/RecipeInstructionStepDtoEntityMapper.ts @@ -1,4 +1,4 @@ -import { RecipeInstructionStepDto } from "../dtos/RecipeInstructionStepDto.js"; +import { RecipeInstructionStepDto } from "../api/dtos/RecipeInstructionStepDto.js"; import { RecipeInstructionStepEntity } from "../entities/RecipeInstructionStepEntity.js"; import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js"; diff --git a/src/mappers/TagDtoEntityMapper.ts b/src/mappers/TagDtoEntityMapper.ts new file mode 100644 index 0000000..c162db5 --- /dev/null +++ b/src/mappers/TagDtoEntityMapper.ts @@ -0,0 +1,44 @@ +import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js"; +import { TagEntity } from "../entities/TagEntity.js"; +import { TagDto } from "../api/dtos/TagDto.js"; + +/** + * Maps between TagDto (API layer) and TagEntity (persistence layer). + */ +export class TagDtoEntityMapper extends AbstractDtoEntityMapper { + + /** + * Maps a TagEntity to a TagDto for outbound API responses. + */ + toDto(entity: TagEntity): TagDto { + const dto = new TagDto(); + this.mapBaseEntityToDto(entity, dto); + dto.description = entity.description; + return dto; + } + + /** + * Maps a TagDto to a new TagEntity. + */ + toEntity(dto: TagDto): TagEntity { + const entity = this.createNewEntity(); + return this.mergeDtoIntoEntity(dto, entity); + } + + /** + * Merges changes from a TagDto into an existing TagEntity. + * Used when updating a tag. + */ + mergeDtoIntoEntity(dto: TagDto, entity: TagEntity): TagEntity { + this.mapBaseDtoToEntity(dto, entity); + entity.description = dto.description; + return entity; + } + + /** + * Creates a blank TagEntity. Required by mergeDtoListIntoEntityList. + */ + createNewEntity(): TagEntity { + return new TagEntity(); + } +} \ No newline at end of file diff --git a/src/mappers/UserDtoEntityMapper.ts b/src/mappers/UserDtoEntityMapper.ts index 675f2fa..7a5a277 100644 --- a/src/mappers/UserDtoEntityMapper.ts +++ b/src/mappers/UserDtoEntityMapper.ts @@ -1,6 +1,6 @@ import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js"; import { UserEntity } from "../entities/UserEntity.js"; -import { UserDto } from "../dtos/UserDto.js"; +import { UserDto } from "../api/dtos/UserDto.js"; export class UserDtoEntityMapper extends AbstractDtoEntityMapper { toDto(entity: UserEntity): UserDto { diff --git a/src/middleware/authenticationMiddleware.ts b/src/middleware/authenticationMiddleware.ts index 27715e8..fbd74dd 100644 --- a/src/middleware/authenticationMiddleware.ts +++ b/src/middleware/authenticationMiddleware.ts @@ -1,9 +1,9 @@ import { NextFunction, Request, Response } from "express"; import jwt from "jsonwebtoken"; import dotenv from "dotenv"; -import { authBasicRoute } from "../endpoints/AuthRestResource.js"; -import { AuthPayload } from "../dtos/AuthPayload.js"; -import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; +import { authBasicRoute } from "../api/endpoints/AuthRestResource.js"; +import { AuthPayload } from "../api/dtos/AuthPayload.js"; +import {HttpStatusCode} from "../api/apiHelpers/HttpStatusCodes.js"; dotenv.config(); diff --git a/src/middleware/authorizationMiddleware.ts b/src/middleware/authorizationMiddleware.ts index d2ff8db..df98d05 100644 --- a/src/middleware/authorizationMiddleware.ts +++ b/src/middleware/authorizationMiddleware.ts @@ -1,7 +1,7 @@ import { NextFunction, Request, Response } from "express"; -import { ForbiddenError } from "../errors/httpErrors.js"; -import { HttpStatusCode } from "../apiHelpers/HttpStatusCodes.js"; -import { UserRole } from "../enums/UserRole.js"; +import { ForbiddenError } from "../api/errors/httpErrors.js"; +import { HttpStatusCode } from "../api/apiHelpers/HttpStatusCodes.js"; +import { UserRole } from "../api/enums/UserRole.js"; /** * Middleware to check if the current user has one of the required roles diff --git a/src/middleware/corsMiddleware.ts b/src/middleware/corsMiddleware.ts index e7597a0..71aab14 100644 --- a/src/middleware/corsMiddleware.ts +++ b/src/middleware/corsMiddleware.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; +import {HttpStatusCode} from "../api/apiHelpers/HttpStatusCodes.js"; /** * Add CORS header diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index 1b335d3..6673a7b 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,6 +1,6 @@ // middleware/errorHandler.ts import { Request, Response, NextFunction } from "express"; -import { HttpError, InternalServerError } from "../errors/httpErrors.js"; +import { HttpError, InternalServerError } from "../api/errors/httpErrors.js"; /** * Express global error-handling middleware. diff --git a/src/middleware/errorLoggerMiddleware.ts b/src/middleware/errorLoggerMiddleware.ts index 094fb2a..0ecc0de 100644 --- a/src/middleware/errorLoggerMiddleware.ts +++ b/src/middleware/errorLoggerMiddleware.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; -import { HttpError } from "../errors/httpErrors.js"; +import { HttpError } from "../api/errors/httpErrors.js"; import { logger } from "../utils/logger.js"; -import { HttpStatusCode } from "../apiHelpers/HttpStatusCodes.js"; +import { HttpStatusCode } from "../api/apiHelpers/HttpStatusCodes.js"; /** * Error logging and handling middleware diff --git a/src/migrations/1771658108802-CreateTagTable.ts b/src/migrations/1771658108802-CreateTagTable.ts new file mode 100644 index 0000000..9e4ad59 --- /dev/null +++ b/src/migrations/1771658108802-CreateTagTable.ts @@ -0,0 +1,46 @@ +import {MigrationInterface, QueryRunner, Table} from "typeorm"; + +/** + * Creates the `tag` table. + */ +export class CreateTagTable1771658108802 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "tag", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "uuid_generate_v4()", + }, + { + name: "description", + type: "varchar", + length: "100", + isUnique: true, + isNullable: false, + }, + { + name: "create_date", + type: "timestamp", + default: "now()", + }, + { + name: "update_date", + type: "timestamp", + default: "now()", + }, + ], + }), + true // ifNotExists + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("tag", true); + } +} diff --git a/src/migrations/1771658131258-CreateRecipeTagTable.ts b/src/migrations/1771658131258-CreateRecipeTagTable.ts new file mode 100644 index 0000000..98dc163 --- /dev/null +++ b/src/migrations/1771658131258-CreateRecipeTagTable.ts @@ -0,0 +1,66 @@ +import {MigrationInterface, QueryRunner, Table, TableForeignKey} from "typeorm"; + +/** + * Creates the `recipe_tag` many-to-many join table. + * + * Both foreign keys are defined with ON DELETE CASCADE so that: + * - deleting a Recipe automatically removes its rows from this table, and + * - deleting a Tag automatically removes its rows from this table. + * In both cases the other side of the relation is left untouched. + */ + +export class CreateRecipeTagTable1771658131258 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "recipe_tag", + columns: [ + { + name: "recipe_id", + type: "uuid", + isPrimary: true, + }, + { + name: "tag_id", + type: "uuid", + isPrimary: true, + }, + ], + }), + true // ifNotExists + ); + + // FK: recipe_tag.recipe_id → recipe.id (cascade on recipe delete) + await queryRunner.createForeignKey( + "recipe_tag", + new TableForeignKey({ + name: "FK_recipe_tag_recipe", + columnNames: ["recipe_id"], + referencedTableName: "recipe", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + onUpdate: "CASCADE", + }) + ); + + // FK: recipe_tag.tag_id → tag.id (cascade on tag delete) + await queryRunner.createForeignKey( + "recipe_tag", + new TableForeignKey({ + name: "FK_recipe_tag_tag", + columnNames: ["tag_id"], + referencedTableName: "tag", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + onUpdate: "CASCADE", + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropForeignKey("recipe_tag", "FK_recipe_tag_tag"); + await queryRunner.dropForeignKey("recipe_tag", "FK_recipe_tag_recipe"); + await queryRunner.dropTable("recipe_tag", true); + } +} diff --git a/src/repositories/TagRepository.ts b/src/repositories/TagRepository.ts new file mode 100644 index 0000000..dfc1314 --- /dev/null +++ b/src/repositories/TagRepository.ts @@ -0,0 +1,24 @@ +import { AbstractRepository } from "./AbstractRepository.js"; +import { TagEntity } from "../entities/TagEntity.js"; + +/** + * Repository for TagEntity. + * The AbstractRepository already provides findById, findAll, create, update, + * and delete. Tag-specific queries can be added here as needed. + */ +export class TagRepository extends AbstractRepository { + constructor() { + super(TagEntity); + } + + /** + * Looks up a tag by its description (case-insensitive). + * Useful for detecting duplicates before creating a new tag. + */ + async findByDescription(description: string): Promise { + return this.repo + .createQueryBuilder("tag") + .where("LOWER(tag.description) = LOWER(:description)", { description }) + .getOne(); + } +} \ No newline at end of file diff --git a/src/utils/encryptionUtils.ts b/src/utils/encryptionUtils.ts index b64e629..c622db6 100644 --- a/src/utils/encryptionUtils.ts +++ b/src/utils/encryptionUtils.ts @@ -1,7 +1,7 @@ import jwt from "jsonwebtoken"; import bcrypt from "bcrypt"; import dotenv from "dotenv"; -import { AuthPayload } from "../dtos/AuthPayload.js"; +import { AuthPayload } from "../api/dtos/AuthPayload.js"; dotenv.config(); const { JWT_SECRET = "" } = process.env; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 77a747b..7f3b061 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,6 +1,6 @@ import winston from "winston"; import path from "path"; -import { HttpStatusCode } from "../apiHelpers/HttpStatusCodes.js"; +import { HttpStatusCode } from "../api/apiHelpers/HttpStatusCodes.js"; // Define log format const logFormat = winston.format.combine(