From b1b714f44e7784873f529210126e920602054228 Mon Sep 17 00:00:00 2001 From: Anika Raemer Date: Sat, 4 Oct 2025 18:01:46 +0200 Subject: [PATCH 1/4] correct cascade delete options --- bruno/recipe-backend/getCompactRecipes.bru | 2 +- bruno/recipe-backend/updateRecipe.bru | 7 ++- src/controllers/RecipeController.ts | 17 +++++- src/data-source.ts | 2 +- src/dtos/RecipeIngredientDto.ts | 3 +- src/dtos/RecipeIngredientGroupDto.ts | 3 +- src/entities/RecipeEntity.ts | 4 +- src/entities/RecipeIngredientEntity.ts | 6 +- src/entities/RecipeIngredientGroupEntity.ts | 8 ++- src/entities/RecipeInstructionStepEntity.ts | 6 +- src/mappers/AbstractDtoEntityMapper.ts | 21 +++++++ src/mappers/CompactRecipeDtoEntityMapper.ts | 12 ++++ src/mappers/RecipeDtoEntityMapper.ts | 55 +++++++++++++---- .../RecipeIngredientDtoEntityMapper.ts | 13 ++++ .../RecipeIngredientGroupDtoEntityMapper.ts | 61 +++++++++++++------ .../RecipeInstructionStepDtoEntityMapper.ts | 10 +++ src/mappers/UserDtoEntityMapper.ts | 10 +++ src/repositories/AbstractRepository.ts | 2 +- src/repositories/RecipeRepository.ts | 17 +++++- 19 files changed, 207 insertions(+), 52 deletions(-) diff --git a/bruno/recipe-backend/getCompactRecipes.bru b/bruno/recipe-backend/getCompactRecipes.bru index b96cdfc..d62a677 100644 --- a/bruno/recipe-backend/getCompactRecipes.bru +++ b/bruno/recipe-backend/getCompactRecipes.bru @@ -11,7 +11,7 @@ get { } auth:bearer { - token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1ODk4Njk4MSwiZXhwIjoxNzU5MDczMzgxfQ.rYvECzhI3Tptse3yVjZvR9RXgs1gkwAt2_5-hpAXvB0 + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTA3NjA2NCwiZXhwIjoxNzU5MTYyNDY0fQ.exf_3fCrarW0LhUqPuadvp89BOUazEXtdSTkGDIAU_Q } settings { diff --git a/bruno/recipe-backend/updateRecipe.bru b/bruno/recipe-backend/updateRecipe.bru index 85a4191..a4b695f 100644 --- a/bruno/recipe-backend/updateRecipe.bru +++ b/bruno/recipe-backend/updateRecipe.bru @@ -11,7 +11,7 @@ put { } auth:bearer { - token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTA3NjA2NCwiZXhwIjoxNzU5MTYyNDY0fQ.exf_3fCrarW0LhUqPuadvp89BOUazEXtdSTkGDIAU_Q + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTE3MjI3MywiZXhwIjoxNzU5MjU4NjczfQ._X_ZtBGtx0_14Nx90ctSQL-ieVPptaPc7WjG3FnyOOA } body:json { @@ -27,7 +27,7 @@ body:json { "id": "9042d658-0102-4e63-8637-a82c5653aa9d", "createdAt": "2025-09-28T10:24:05.429Z", "updatedAt": "2025-09-28T10:24:05.429Z", - "text": "Mürbteig von 400 g Mehl herstellen", + "text": "Mürbteig von 400 g Mehl herstellen.", "sortOrder": 1 }, { @@ -57,6 +57,9 @@ body:json { "updatedAt": "2025-09-28T10:24:05.429Z", "text": "Backen", "sortOrder": 5 + }, { + "text": "Essen", + "sortOrder": 6 } ], "ingredientGroups": [ diff --git a/src/controllers/RecipeController.ts b/src/controllers/RecipeController.ts index 44b3b37..da6b8c1 100644 --- a/src/controllers/RecipeController.ts +++ b/src/controllers/RecipeController.ts @@ -6,6 +6,7 @@ import { RecipeInstructionStepDto } from "../dtos/RecipeInstructionStepDto.js"; import { RecipeIngredientGroupDto } from "../dtos/RecipeIngredientGroupDto.js"; import { RecipeIngredientDto } from "../dtos/RecipeIngredientDto.js"; import { Entity } from "typeorm"; +import { RecipeEntity } from "../entities/RecipeEntity.js"; /** * Controls all recipe specific actions @@ -38,9 +39,19 @@ export class RecipeController { if (!this.isRecipeDtoValid(dto)) { throw new ValidationError("recipe data is not valid!") } - const recipeEntity = this.mapper.toEntity(dto); - // @todo doesn't create new ingredient groups, ingredients or instruction steps yet - const savedEntity = await this.recipeRepository.save(recipeEntity); + const recipeId = dto.id + if(recipeId === undefined){ + throw new ValidationError("Trying to update recipe without ID!") + } + // Load current version of recipe from database + const recipeEntity = await this.recipeRepository.findById(recipeId); + if(!recipeEntity){ + throw new ValidationError("No recipe with ID " + recipeId + " found in database!") + } + // merge changes into entity + this.mapper.mergeDtoIntoEntity(dto, recipeEntity); + // persist changes + const savedEntity = await this.recipeRepository.update(recipeEntity); return this.mapper.toDto(savedEntity); } /** diff --git a/src/data-source.ts b/src/data-source.ts index b05c91b..89636d4 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -24,7 +24,7 @@ export const AppDataSource = new DataSource({ synchronize: NODE_ENV === "dev" ? false : false, //logging logs sql command on the terminal - logging: NODE_ENV === "dev" ? false : false, + logging: NODE_ENV === "dev" ? ["query", "error"] : false, entities: [join(__dirname, "/entities/*.{js, ts}")], migrations: [join(__dirname, "/migrations/*.js")], subscribers: [], diff --git a/src/dtos/RecipeIngredientDto.ts b/src/dtos/RecipeIngredientDto.ts index 20cfa9f..3d7c91f 100644 --- a/src/dtos/RecipeIngredientDto.ts +++ b/src/dtos/RecipeIngredientDto.ts @@ -1,4 +1,3 @@ -import { UUID } from "crypto"; import { AbstractDto } from "./AbstractDto.js"; export class RecipeIngredientDto extends AbstractDto{ @@ -7,5 +6,5 @@ export class RecipeIngredientDto extends AbstractDto{ amount?: number; unit?: string; sortOrder!: number; - ingredientGroupId?: UUID; + ingredientGroupId?: string; } \ No newline at end of file diff --git a/src/dtos/RecipeIngredientGroupDto.ts b/src/dtos/RecipeIngredientGroupDto.ts index 32d6cc0..324d3c5 100644 --- a/src/dtos/RecipeIngredientGroupDto.ts +++ b/src/dtos/RecipeIngredientGroupDto.ts @@ -1,10 +1,9 @@ -import { UUID } from "crypto"; import { AbstractDto } from "./AbstractDto.js"; import { RecipeIngredientDto } from "./RecipeIngredientDto.js"; export class RecipeIngredientGroupDto extends AbstractDto{ title?: string; sortOrder!: number; - recipeId?: UUID; + recipeId?: string; ingredients!: RecipeIngredientDto[]; } \ No newline at end of file diff --git a/src/entities/RecipeEntity.ts b/src/entities/RecipeEntity.ts index 726a505..2f3b8fe 100644 --- a/src/entities/RecipeEntity.ts +++ b/src/entities/RecipeEntity.ts @@ -19,13 +19,13 @@ export class RecipeEntity extends AbstractEntity { // make sure not to induce a circular dependency! user arrow function without brackets! @OneToMany(() => RecipeInstructionStepEntity, (instructionStep) => instructionStep.recipe, { - cascade: true, + cascade: true }) instructionSteps!: RecipeInstructionStepEntity[]; // make sure not to induce a circular dependency! user arrow function without brackets! @OneToMany(() => RecipeIngredientGroupEntity, (ingredientGroup) => ingredientGroup.recipe, { - cascade: true, + cascade: true }) ingredientGroups!: Relation[]; } \ No newline at end of file diff --git a/src/entities/RecipeIngredientEntity.ts b/src/entities/RecipeIngredientEntity.ts index eb90523..6b5a466 100644 --- a/src/entities/RecipeIngredientEntity.ts +++ b/src/entities/RecipeIngredientEntity.ts @@ -24,7 +24,11 @@ export class RecipeIngredientEntity extends AbstractEntity { @JoinColumn({name: "recipe_ingredient_group_id"}) @ManyToOne(() => RecipeIngredientGroupEntity, (ingredientGroup) => ingredientGroup.ingredients, - {onDelete: "CASCADE", nullable: false} + { + onDelete: "CASCADE", + nullable: false, + orphanedRowAction: "delete" + } ) ingredientGroup!: Relation; } \ No newline at end of file diff --git a/src/entities/RecipeIngredientGroupEntity.ts b/src/entities/RecipeIngredientGroupEntity.ts index 64c64c3..6cd763e 100644 --- a/src/entities/RecipeIngredientGroupEntity.ts +++ b/src/entities/RecipeIngredientGroupEntity.ts @@ -16,12 +16,16 @@ export class RecipeIngredientGroupEntity extends AbstractEntity { @JoinColumn({name: "recipe_id"}) @ManyToOne(() => RecipeEntity, (recipe) => recipe.ingredientGroups, - {onDelete: "CASCADE", nullable: false} + { + onDelete: "CASCADE", + nullable: false, + orphanedRowAction: "delete" // delete removed groups + } ) recipe!: Relation; @OneToMany(() => RecipeIngredientEntity, (ingredient) => ingredient.ingredientGroup, { - cascade: true, + cascade: true }) ingredients!: Relation[]; diff --git a/src/entities/RecipeInstructionStepEntity.ts b/src/entities/RecipeInstructionStepEntity.ts index ed238d1..1a1417f 100644 --- a/src/entities/RecipeInstructionStepEntity.ts +++ b/src/entities/RecipeInstructionStepEntity.ts @@ -15,7 +15,11 @@ export class RecipeInstructionStepEntity extends AbstractEntity { @JoinColumn({name: "recipe_id"}) @ManyToOne(() => RecipeEntity, (recipe) => recipe.instructionSteps, - {onDelete: "CASCADE", nullable: false} + { + onDelete: "CASCADE", + nullable: false, + orphanedRowAction: "delete" // delete removed groups + } ) recipe!: Relation; diff --git a/src/mappers/AbstractDtoEntityMapper.ts b/src/mappers/AbstractDtoEntityMapper.ts index 51a5f27..d2ea746 100644 --- a/src/mappers/AbstractDtoEntityMapper.ts +++ b/src/mappers/AbstractDtoEntityMapper.ts @@ -28,4 +28,25 @@ export abstract class AbstractDtoEntityMapper< // Abstract methods to be implemented by subclasses abstract toDto(entity: E): D; abstract toEntity(dto: D): E; + abstract mergeDtoIntoEntity(dto: D, entity: E): E; + abstract createNewEntity() : E; + + mergeDtoListIntoEntityList(dtos: D[], entities: E[]) : E[]{ + const updatedEntities: E[] = []; + const existingMap = new Map(entities?.map(e => [e.id, e]) ?? []); + + for (const dto of dtos) { + if (dto.id && existingMap.has(dto.id)) { + // update existing + const entity = existingMap.get(dto.id)!; + updatedEntities.push(this.mergeDtoIntoEntity(dto, entity)); + } else { + // create new + const newEntity = this.createNewEntity(); + updatedEntities.push(this.mergeDtoIntoEntity(dto, newEntity)); + } + } + + return updatedEntities; + } } diff --git a/src/mappers/CompactRecipeDtoEntityMapper.ts b/src/mappers/CompactRecipeDtoEntityMapper.ts index 77f2268..8f2c2c4 100644 --- a/src/mappers/CompactRecipeDtoEntityMapper.ts +++ b/src/mappers/CompactRecipeDtoEntityMapper.ts @@ -17,4 +17,16 @@ export class CompactRecipeDtoEntityMapper extends AbstractDtoEntityMapper this.instructionStepMapper.toDto(stepEntity)); - dto.instructions = instructionStepDtos; + dto.instructions = entity.instructionSteps.map((stepEntity) => this.instructionStepMapper.toDto(stepEntity)); + // @todo map ids dto.instructions.forEach(step => step.recipeId = entity.id); // set recipe relation explicitly! + // map ingredient groups - const ingredientGroupEntities = entity.ingredientGroups; - const ingredientGroupDtos = ingredientGroupEntities.map((groupEntity) => this.ingredientGroupMapper.toDto(groupEntity)); - dto.ingredientGroups = ingredientGroupDtos; + dto.ingredientGroups = entity.ingredientGroups.map((groupEntity) => this.ingredientGroupMapper.toDto(groupEntity)); return dto; } @@ -42,16 +40,47 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper this.instructionStepMapper.toEntity(stepDto)); - entity.instructionSteps = instructionStepEntities; + entity.instructionSteps = dto.instructions.map((stepDto) => { + const stepEntity = this.instructionStepMapper.toEntity(stepDto); + + // Always set the relation + stepEntity.recipe = entity; + + // If it's a new step (no id from client), let DB generate a new UUID + if (!stepDto.id) { + delete (stepEntity as any).id; + } + + return stepEntity; + }); // map ingredient groups - const ingredientGroupDtos = dto.ingredientGroups; - const ingredientGroupEntities = ingredientGroupDtos.map((ingredientGroupDto) => this.ingredientGroupMapper.toEntity(ingredientGroupDto)); - entity.ingredientGroups = ingredientGroupEntities; + entity.ingredientGroups = dto.ingredientGroups.map((groupDto) => { + const groupEntity = this.ingredientGroupMapper.toEntity(groupDto); + groupEntity.recipe = entity; + if (!groupDto.id) { + delete (groupEntity as any).id; + } + return groupEntity; + }); - return entity; + return entity; } + mergeDtoIntoEntity(dto: RecipeDto, entity: RecipeEntity): RecipeEntity { + entity.title = dto.title; + entity.amount = dto.amount; + entity.amountDescription = dto.amountDescription; + + // --- Instruction Steps --- + entity.instructionSteps = this.instructionStepMapper.mergeDtoListIntoEntityList(dto.instructions, entity.instructionSteps); + + // --- Ingredient Groups --- + entity.ingredientGroups = this.ingredientGroupMapper.mergeDtoListIntoEntityList(dto.ingredientGroups, entity.ingredientGroups); + return entity + } + + createNewEntity(): RecipeEntity { + return new RecipeEntity(); + } } \ No newline at end of file diff --git a/src/mappers/RecipeIngredientDtoEntityMapper.ts b/src/mappers/RecipeIngredientDtoEntityMapper.ts index 0a8baab..fb1f6cd 100644 --- a/src/mappers/RecipeIngredientDtoEntityMapper.ts +++ b/src/mappers/RecipeIngredientDtoEntityMapper.ts @@ -29,4 +29,17 @@ export class RecipeIngredientDtoEntityMapper extends AbstractDtoEntityMapper{ - constructor( - private ingredientMapper : RecipeIngredientDtoEntityMapper - ){ - super(); - } +export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMapper { + constructor( + private ingredientMapper: RecipeIngredientDtoEntityMapper + ) { + super(); + } - toDto(entity: RecipeIngredientGroupEntity): RecipeIngredientGroupDto { - const dto = new RecipeIngredientGroupDto(); - this.mapBaseEntityToDto(entity, dto); + toDto(entity: RecipeIngredientGroupEntity): RecipeIngredientGroupDto { + const dto = new RecipeIngredientGroupDto(); + this.mapBaseEntityToDto(entity, dto); - dto.title = entity.title; - dto.sortOrder = entity.sortOrder + dto.title = entity.title; + dto.sortOrder = entity.sortOrder - // map ingredients - const ingredientEntities = entity.ingredients; - const ingredientDtos = ingredientEntities?.map((ingredientEntity) => this.ingredientMapper.toDto(ingredientEntity)); - dto.ingredients = ingredientDtos; + // map ingredients + dto.ingredients = entity.ingredients?.map((ingredientEntity) => this.ingredientMapper.toDto(ingredientEntity)); - return dto; - } + return dto; + } toEntity(dto: RecipeIngredientGroupDto): RecipeIngredientGroupEntity { const entity = new RecipeIngredientGroupEntity(); @@ -33,11 +31,34 @@ export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMappe entity.sortOrder = dto.sortOrder // map ingredients - const ingredientDtos = dto.ingredients; - const ingredientEntities = ingredientDtos?.map((ingredientDto) => this.ingredientMapper.toEntity(ingredientDto)); - entity.ingredients = ingredientEntities; + entity.ingredients = dto.ingredients.map((ingredientDto) => { + const ingredientEntity = this.ingredientMapper.toEntity(ingredientDto); + ingredientEntity.ingredientGroup = entity; + // remove id from new entity completely and allow ORM to generate a new one + if (!ingredientDto.id) { + delete (ingredientEntity as any).id; + } + return ingredientEntity; + }); return entity; } + createNewEntity(): RecipeIngredientGroupEntity { + return new RecipeIngredientGroupEntity(); + } + + mergeDtoIntoEntity(dto: RecipeIngredientGroupDto, entity: RecipeIngredientGroupEntity): RecipeIngredientGroupEntity { + entity.title = dto.title; + entity.sortOrder = dto.sortOrder; + + // sync ingredients inside each group + entity.ingredients = this.ingredientMapper.mergeDtoListIntoEntityList( + dto.ingredients, + entity.ingredients, + ); + + return entity; + } + } \ No newline at end of file diff --git a/src/mappers/RecipeInstructionStepDtoEntityMapper.ts b/src/mappers/RecipeInstructionStepDtoEntityMapper.ts index be7ca3a..14f02ff 100644 --- a/src/mappers/RecipeInstructionStepDtoEntityMapper.ts +++ b/src/mappers/RecipeInstructionStepDtoEntityMapper.ts @@ -24,4 +24,14 @@ export class RecipeInstructionStepDtoEntityMapper extends AbstractDtoEntityMappe return entity; } + createNewEntity(): RecipeInstructionStepEntity { + return new RecipeInstructionStepEntity(); + } + + mergeDtoIntoEntity(dto: RecipeInstructionStepDto, entity: RecipeInstructionStepEntity): RecipeInstructionStepEntity { + entity.text = dto.text; + entity.sortOrder = dto.sortOrder; + return entity; + } + } \ No newline at end of file diff --git a/src/mappers/UserDtoEntityMapper.ts b/src/mappers/UserDtoEntityMapper.ts index 927e4f6..675f2fa 100644 --- a/src/mappers/UserDtoEntityMapper.ts +++ b/src/mappers/UserDtoEntityMapper.ts @@ -20,6 +20,16 @@ export class UserDtoEntityMapper extends AbstractDtoEntityMapper { await this.repo.delete(id as any); } - async save(entity: T): Promise { + async update(entity: T): Promise { return this.repo.save(entity); } diff --git a/src/repositories/RecipeRepository.ts b/src/repositories/RecipeRepository.ts index ef48156..1b7e620 100644 --- a/src/repositories/RecipeRepository.ts +++ b/src/repositories/RecipeRepository.ts @@ -1,5 +1,6 @@ import { AbstractRepository } from "./AbstractRepository.js"; import { RecipeEntity } from "../entities/RecipeEntity.js"; +import { AppDataSource } from "../data-source.js"; export class RecipeRepository extends AbstractRepository { constructor() { @@ -20,6 +21,20 @@ export class RecipeRepository extends AbstractRepository { 'instructionSteps' ] }); - } + + async updateRecipe(entity: RecipeEntity): Promise { + return AppDataSource.transaction(async (em) => { + // load existing data + const existing = await this.repo.findOneOrFail({ + where: { id: entity.id }, + relations: ["instructionSteps", "ingredientGroups", "ingredientGroups.ingredients"], + }); + + // merge new entity and existing entity + this.repo.merge(existing, entity); + return this.repo.save(existing); + }); +} + } \ No newline at end of file From 7e831cfb64617df77cfbf3808eb73b170b4487c6 Mon Sep 17 00:00:00 2001 From: Anika Raemer Date: Sat, 4 Oct 2025 18:16:49 +0200 Subject: [PATCH 2/4] renaming and docs --- src/data-source.ts | 7 ++++ src/endpoints/AuthPoint.ts | 9 +++- src/endpoints/CompactRecipePoint.ts | 5 ++- src/endpoints/RecipePoint.ts | 16 +++++++- src/endpoints/UserPoint.ts | 7 +++- src/entities/AbstractEntity.ts | 3 ++ src/entities/UserEntity.ts | 4 +- .../AuthHandler.ts} | 2 +- .../CompactRecipeHandler.ts} | 2 +- .../RecipeHandler.ts} | 4 +- .../UserHandler.ts} | 2 +- src/mappers/AbstractDtoEntityMapper.ts | 41 ++++++++++++++++--- src/mappers/CompactRecipeDtoEntityMapper.ts | 4 +- src/repositories/AbstractRepository.ts | 12 ++---- 14 files changed, 86 insertions(+), 32 deletions(-) rename src/{controllers/AuthController.ts => handlers/AuthHandler.ts} (98%) rename src/{controllers/CompactRecipeController.ts => handlers/CompactRecipeHandler.ts} (96%) rename src/{controllers/RecipeController.ts => handlers/RecipeHandler.ts} (97%) rename src/{controllers/UserController.ts => handlers/UserHandler.ts} (98%) diff --git a/src/data-source.ts b/src/data-source.ts index 89636d4..d0b32e5 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -11,9 +11,16 @@ const __dirname = dirname(__filename); dotenv.config(); +/** + * Load config + */ const { DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE, NODE_ENV } = process.env; + /** + * Configures data source + */ + export const AppDataSource = new DataSource({ type: "postgres", host: DB_HOST, diff --git a/src/endpoints/AuthPoint.ts b/src/endpoints/AuthPoint.ts index 7745240..f2b91fb 100644 --- a/src/endpoints/AuthPoint.ts +++ b/src/endpoints/AuthPoint.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { AuthController } from "../controllers/AuthController.js"; +import { AuthHandler } from "../handlers/AuthHandler.js"; import { UserRepository } from "../repositories/UserRepository.js"; import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; import { @@ -14,8 +14,13 @@ export const authBasicRoute = "/auth" const router = Router(); const userRepository = new UserRepository(); const mapper = new UserDtoEntityMapper(); -const authController = new AuthController(userRepository, mapper); +const authController = new AuthHandler(userRepository, mapper); +/** + * Login using username and password + * Consumes LoginRequestDto + * Responds with LoginResponseDto + */ router.post("/login", async (req, res) => { console.log("login point called") try { diff --git a/src/endpoints/CompactRecipePoint.ts b/src/endpoints/CompactRecipePoint.ts index ea1db47..17e6d49 100644 --- a/src/endpoints/CompactRecipePoint.ts +++ b/src/endpoints/CompactRecipePoint.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { asyncHandler } from "../utils/asyncHandler.js"; import { RecipeRepository } from "../repositories/RecipeRepository.js"; -import { CompactRecipeController } from "../controllers/CompactRecipeController.js"; +import { CompactRecipeHandler } from "../handlers/CompactRecipeHandler.js"; import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js"; /** @@ -12,9 +12,10 @@ const router = Router(); // Inject repo + mapper here const recipeRepository = new RecipeRepository(); const compactRecipeMapper = new CompactRecipeDtoEntityMapper(); -const compactRecipeController = new CompactRecipeController(recipeRepository, compactRecipeMapper); +const compactRecipeController = new CompactRecipeHandler(recipeRepository, compactRecipeMapper); /** * Load header data of all recipes + * Responds with a list of CompactRecipeDtos */ router.get( "/", diff --git a/src/endpoints/RecipePoint.ts b/src/endpoints/RecipePoint.ts index dde7a17..3949add 100644 --- a/src/endpoints/RecipePoint.ts +++ b/src/endpoints/RecipePoint.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { RecipeRepository } from "../repositories/RecipeRepository.js"; import { RecipeDtoEntityMapper } from "../mappers/RecipeDtoEntityMapper.js"; -import { RecipeController } from "../controllers/RecipeController.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"; @@ -20,10 +20,12 @@ const recipeIngredientMapper = new RecipeIngredientDtoEntityMapper(); const recipeIngredientGroupMapper = new RecipeIngredientGroupDtoEntityMapper(recipeIngredientMapper); const recipeInstructionStepMapper = new RecipeInstructionStepDtoEntityMapper(); const recipeMapper = new RecipeDtoEntityMapper(recipeInstructionStepMapper, recipeIngredientGroupMapper); -const recipeController = new RecipeController(recipeRepository, recipeMapper); +const recipeController = new RecipeHandler(recipeRepository, recipeMapper); /** * Create new recipe + * Consumes: RecipeDto + * Responds with RecipeDto */ router.post( "/", @@ -34,6 +36,10 @@ router.post( }) ); +/** + * Load recipe by id + * Responds with RecipeDto + */ router.get( "/:id", asyncHandler(async(req, res) => { @@ -43,6 +49,12 @@ router.get( }) ); +/** + * Saves existing recipe + * Also handles changes to instructions steps and ingredient (groups) + * Consumes: RecipeDto + * Responds with RecipeDto + */ router.put( "/:id", asyncHandler(async(req, res) =>{ diff --git a/src/endpoints/UserPoint.ts b/src/endpoints/UserPoint.ts index edf9167..58d8266 100644 --- a/src/endpoints/UserPoint.ts +++ b/src/endpoints/UserPoint.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { UserController } from "../controllers/UserController.js"; +import { UserHandler } from "../handlers/UserHandler.js"; import { CreateUserRequestDto } from "../dtos/CreateUserRequestDto.js"; import { UserRepository } from "../repositories/UserRepository.js"; import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; @@ -13,10 +13,12 @@ const router = Router(); // Inject repo + mapper here const userRepository = new UserRepository(); const userMapper = new UserDtoEntityMapper(); -const userController = new UserController(userRepository, userMapper); +const userController = new UserHandler(userRepository, userMapper); /** * Create a new user + * Consumes CreateUserRequestDto + * Responds with UserDto */ router.post( "/", @@ -29,6 +31,7 @@ router.post( /** * Get user data for current user + * Responds with UserDto */ router.get("/me", asyncHandler(async (req, res) => { diff --git a/src/entities/AbstractEntity.ts b/src/entities/AbstractEntity.ts index 86401fd..07fb2c2 100644 --- a/src/entities/AbstractEntity.ts +++ b/src/entities/AbstractEntity.ts @@ -4,6 +4,9 @@ import { UpdateDateColumn, } from "typeorm"; +/** + * Abstract entity containing basic fields that all entities have in common + */ export abstract class AbstractEntity { @PrimaryGeneratedColumn("uuid") id?: string; diff --git a/src/entities/UserEntity.ts b/src/entities/UserEntity.ts index c306eac..f53b82c 100644 --- a/src/entities/UserEntity.ts +++ b/src/entities/UserEntity.ts @@ -1,7 +1,9 @@ import { Entity, Column } from "typeorm"; import { AbstractEntity } from "./AbstractEntity.js"; -// @todo Add migration to update table +/** + * Entity describing a user + */ @Entity({ name: "user" }) export class UserEntity extends AbstractEntity { @Column({ nullable: false, name: "user_name" }) diff --git a/src/controllers/AuthController.ts b/src/handlers/AuthHandler.ts similarity index 98% rename from src/controllers/AuthController.ts rename to src/handlers/AuthHandler.ts index 15664a6..32065fb 100644 --- a/src/controllers/AuthController.ts +++ b/src/handlers/AuthHandler.ts @@ -9,7 +9,7 @@ import { LoginRequestDto } from "../dtos/LoginRequestDto.js"; * Controller responsible for authentication, e.g., login or issueing a token with extended * lifetime */ -export class AuthController { +export class AuthHandler { constructor( private userRepository: UserRepository, private mapper: UserDtoEntityMapper diff --git a/src/controllers/CompactRecipeController.ts b/src/handlers/CompactRecipeHandler.ts similarity index 96% rename from src/controllers/CompactRecipeController.ts rename to src/handlers/CompactRecipeHandler.ts index 395f3d9..dfcebf9 100644 --- a/src/controllers/CompactRecipeController.ts +++ b/src/handlers/CompactRecipeHandler.ts @@ -6,7 +6,7 @@ import { RecipeRepository } from "../repositories/RecipeRepository.js"; /** * Responsible for loading recipe header data */ -export class CompactRecipeController { +export class CompactRecipeHandler { constructor( private repository: RecipeRepository, private mapper: CompactRecipeDtoEntityMapper diff --git a/src/controllers/RecipeController.ts b/src/handlers/RecipeHandler.ts similarity index 97% rename from src/controllers/RecipeController.ts rename to src/handlers/RecipeHandler.ts index da6b8c1..eee5eda 100644 --- a/src/controllers/RecipeController.ts +++ b/src/handlers/RecipeHandler.ts @@ -5,13 +5,11 @@ 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 { Entity } from "typeorm"; -import { RecipeEntity } from "../entities/RecipeEntity.js"; /** * Controls all recipe specific actions */ -export class RecipeController { +export class RecipeHandler { constructor( private recipeRepository: RecipeRepository, private mapper: RecipeDtoEntityMapper diff --git a/src/controllers/UserController.ts b/src/handlers/UserHandler.ts similarity index 98% rename from src/controllers/UserController.ts rename to src/handlers/UserHandler.ts index 68ad0ef..bccb8e3 100644 --- a/src/controllers/UserController.ts +++ b/src/handlers/UserHandler.ts @@ -9,7 +9,7 @@ import { UUID } from "crypto"; /** * Controls all user specific actions */ -export class UserController { +export class UserHandler { constructor( private userRepository: UserRepository, private mapper: UserDtoEntityMapper diff --git a/src/mappers/AbstractDtoEntityMapper.ts b/src/mappers/AbstractDtoEntityMapper.ts index d2ea746..dd878bc 100644 --- a/src/mappers/AbstractDtoEntityMapper.ts +++ b/src/mappers/AbstractDtoEntityMapper.ts @@ -25,12 +25,16 @@ export abstract class AbstractDtoEntityMapper< return entity; } - // Abstract methods to be implemented by subclasses - abstract toDto(entity: E): D; - abstract toEntity(dto: D): E; - abstract mergeDtoIntoEntity(dto: D, entity: E): E; - abstract createNewEntity() : E; - + /** + * Merge entity list with changes contained in DTO list + * @param dtos List of dtos + * @param entities List of entities + * @returns Merged list + * + * elements no longer contained in the dto list will be removed from the entity list + * new elements will be mapped to entity and added to the entity list + * existing elements will be updated + */ mergeDtoListIntoEntityList(dtos: D[], entities: E[]) : E[]{ const updatedEntities: E[] = []; const existingMap = new Map(entities?.map(e => [e.id, e]) ?? []); @@ -49,4 +53,29 @@ export abstract class AbstractDtoEntityMapper< return updatedEntities; } + + // Abstract methods to be implemented by subclasses + /** + * Maps an entity to DTO + * @param entity Entity that is mapped to DTO + */ + abstract toDto(entity: E): D; + /** + * Maps a DTO to entity + * @param dto DTO to map to entity + */ + abstract toEntity(dto: D): E; + /** + * Merge changes in DTO into entity + * @param dto Dto containing changes + * @param entity existing entity + * + * Used for merging user changes (DTO) into the existing entity (database). + */ + abstract mergeDtoIntoEntity(dto: D, entity: E): E; + /** + * Defines how to create a new entity. Required by mergeDtoListIntoEntityList + * to add new elements to the list + */ + abstract createNewEntity() : E; } diff --git a/src/mappers/CompactRecipeDtoEntityMapper.ts b/src/mappers/CompactRecipeDtoEntityMapper.ts index 8f2c2c4..b8040a3 100644 --- a/src/mappers/CompactRecipeDtoEntityMapper.ts +++ b/src/mappers/CompactRecipeDtoEntityMapper.ts @@ -17,11 +17,11 @@ export class CompactRecipeDtoEntityMapper extends AbstractDtoEntityMapper { protected repo: Repository; @@ -22,15 +25,6 @@ export abstract class AbstractRepository { 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); } From 7ec4324fdea3b6360ff3e2aa0b31d011d90dec51 Mon Sep 17 00:00:00 2001 From: Anika Raemer Date: Sat, 4 Oct 2025 21:04:05 +0200 Subject: [PATCH 3/4] add search for recipe title --- src/endpoints/CompactRecipePoint.ts | 6 ++++-- src/handlers/CompactRecipeHandler.ts | 15 +++++++++++++++ src/repositories/RecipeRepository.ts | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/endpoints/CompactRecipePoint.ts b/src/endpoints/CompactRecipePoint.ts index 17e6d49..0df0e0a 100644 --- a/src/endpoints/CompactRecipePoint.ts +++ b/src/endpoints/CompactRecipePoint.ts @@ -12,7 +12,7 @@ const router = Router(); // Inject repo + mapper here const recipeRepository = new RecipeRepository(); const compactRecipeMapper = new CompactRecipeDtoEntityMapper(); -const compactRecipeController = new CompactRecipeHandler(recipeRepository, compactRecipeMapper); +const compactRecipeHandler = new CompactRecipeHandler(recipeRepository, compactRecipeMapper); /** * Load header data of all recipes * Responds with a list of CompactRecipeDtos @@ -20,7 +20,9 @@ const compactRecipeController = new CompactRecipeHandler(recipeRepository, compa router.get( "/", asyncHandler(async (req, res) => { - const response = await compactRecipeController.getAllCompactRecipes(); + // extract search string from query parameters, convert to lower case for case insensitive search + const searchString : string = req.query.search ? req.query.search.toString().toLowerCase() : ""; + const response = await compactRecipeHandler.getMatchingRecipes(searchString); res.status(201).json(response); }) ); diff --git a/src/handlers/CompactRecipeHandler.ts b/src/handlers/CompactRecipeHandler.ts index dfcebf9..6e332e2 100644 --- a/src/handlers/CompactRecipeHandler.ts +++ b/src/handlers/CompactRecipeHandler.ts @@ -25,4 +25,19 @@ export class CompactRecipeHandler { }); return recipeDtos; } + + /** + * Get all recipes matching search + * + * Recipe title must contain type string + * @todo Full text search?? + */ + async getMatchingRecipes(searchString : string){ + if(!searchString || searchString.length===0){ + // get all + return this.getAllCompactRecipes(); + } else { + return this.repository.findCompactRecipeBySearch(searchString); + } + } } \ No newline at end of file diff --git a/src/repositories/RecipeRepository.ts b/src/repositories/RecipeRepository.ts index 1b7e620..493b7f0 100644 --- a/src/repositories/RecipeRepository.ts +++ b/src/repositories/RecipeRepository.ts @@ -1,6 +1,7 @@ import { AbstractRepository } from "./AbstractRepository.js"; import { RecipeEntity } from "../entities/RecipeEntity.js"; import { AppDataSource } from "../data-source.js"; +import { Like } from "typeorm"; export class RecipeRepository extends AbstractRepository { constructor() { @@ -23,6 +24,23 @@ export class RecipeRepository extends AbstractRepository { }); } + /** + * Find all recipes matching the search. Currently it only searches on the title. Fetches only recipe header data but no relations. + * @param searchString String to search for + * @returns List of recipe entities matching the search criteria + */ + async findCompactRecipeBySearch(searchString : string): Promise{ + // @todo doesn't work like expected... + return this.repo.find( + { where: {title: Like(`%${searchString}%`)}} + ); + } + + /** + * Update recipe and relations + * @param entity Updated entity + * @returns Updated Entity + */ async updateRecipe(entity: RecipeEntity): Promise { return AppDataSource.transaction(async (em) => { // load existing data From 760c91af567aab5e998d40748e4ca4d05cd9e6e5 Mon Sep 17 00:00:00 2001 From: Anika Raemer Date: Tue, 7 Oct 2025 19:26:59 +0200 Subject: [PATCH 4/4] fix core middleware and make search case insensitive --- src/endpoints/CompactRecipePoint.ts | 1 + src/index.ts | 17 ++++++++++++++++- src/repositories/RecipeRepository.ts | 4 ++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/endpoints/CompactRecipePoint.ts b/src/endpoints/CompactRecipePoint.ts index 0df0e0a..e7c6956 100644 --- a/src/endpoints/CompactRecipePoint.ts +++ b/src/endpoints/CompactRecipePoint.ts @@ -22,6 +22,7 @@ router.get( asyncHandler(async (req, res) => { // extract search string from query parameters, convert to lower case for case insensitive search const searchString : string = req.query.search ? req.query.search.toString().toLowerCase() : ""; + console.log("Searching for recipes with title containing", searchString) const response = await compactRecipeHandler.getMatchingRecipes(searchString); res.status(201).json(response); }) diff --git a/src/index.ts b/src/index.ts index 73cf6fb..d0d06fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,9 +27,24 @@ async function startServer() { await AppDataSource.runMigrations(); console.log("Migrations executed"); + // Enable CORS before anything else + // @todo move to middleware util + app.use((req: Request, res: Response, next: NextFunction) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + // Handle preflight requests quickly + if (req.method === 'OPTIONS') { + return res.sendStatus(200); + } + + next(); + }); + // Activate Authentication app.use(authentication); - + // Setup routes app.use(authBasicRoute, authRoutes); app.use("/user", userRoutes); diff --git a/src/repositories/RecipeRepository.ts b/src/repositories/RecipeRepository.ts index 493b7f0..8c62cb2 100644 --- a/src/repositories/RecipeRepository.ts +++ b/src/repositories/RecipeRepository.ts @@ -1,7 +1,7 @@ import { AbstractRepository } from "./AbstractRepository.js"; import { RecipeEntity } from "../entities/RecipeEntity.js"; import { AppDataSource } from "../data-source.js"; -import { Like } from "typeorm"; +import { ILike, Like } from "typeorm"; export class RecipeRepository extends AbstractRepository { constructor() { @@ -32,7 +32,7 @@ export class RecipeRepository extends AbstractRepository { async findCompactRecipeBySearch(searchString : string): Promise{ // @todo doesn't work like expected... return this.repo.find( - { where: {title: Like(`%${searchString}%`)}} + { where: {title: ILike(`%${searchString}%`)}} ); }