Compare commits

..

No commits in common. "760c91af567aab5e998d40748e4ca4d05cd9e6e5" and "e33dfdb84588a0fd6745b0d5b035de44bd9c6bf7" have entirely different histories.

29 changed files with 77 additions and 337 deletions

View file

@ -11,7 +11,7 @@ get {
} }
auth:bearer { auth:bearer {
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTA3NjA2NCwiZXhwIjoxNzU5MTYyNDY0fQ.exf_3fCrarW0LhUqPuadvp89BOUazEXtdSTkGDIAU_Q token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1ODk4Njk4MSwiZXhwIjoxNzU5MDczMzgxfQ.rYvECzhI3Tptse3yVjZvR9RXgs1gkwAt2_5-hpAXvB0
} }
settings { settings {

View file

@ -11,7 +11,7 @@ put {
} }
auth:bearer { auth:bearer {
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTE3MjI3MywiZXhwIjoxNzU5MjU4NjczfQ._X_ZtBGtx0_14Nx90ctSQL-ieVPptaPc7WjG3FnyOOA token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTA3NjA2NCwiZXhwIjoxNzU5MTYyNDY0fQ.exf_3fCrarW0LhUqPuadvp89BOUazEXtdSTkGDIAU_Q
} }
body:json { body:json {
@ -27,7 +27,7 @@ body:json {
"id": "9042d658-0102-4e63-8637-a82c5653aa9d", "id": "9042d658-0102-4e63-8637-a82c5653aa9d",
"createdAt": "2025-09-28T10:24:05.429Z", "createdAt": "2025-09-28T10:24:05.429Z",
"updatedAt": "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 "sortOrder": 1
}, },
{ {
@ -57,9 +57,6 @@ body:json {
"updatedAt": "2025-09-28T10:24:05.429Z", "updatedAt": "2025-09-28T10:24:05.429Z",
"text": "Backen", "text": "Backen",
"sortOrder": 5 "sortOrder": 5
}, {
"text": "Essen",
"sortOrder": 6
} }
], ],
"ingredientGroups": [ "ingredientGroups": [

View file

@ -9,7 +9,7 @@ import { LoginRequestDto } from "../dtos/LoginRequestDto.js";
* Controller responsible for authentication, e.g., login or issueing a token with extended * Controller responsible for authentication, e.g., login or issueing a token with extended
* lifetime * lifetime
*/ */
export class AuthHandler { export class AuthController {
constructor( constructor(
private userRepository: UserRepository, private userRepository: UserRepository,
private mapper: UserDtoEntityMapper private mapper: UserDtoEntityMapper

View file

@ -6,7 +6,7 @@ import { RecipeRepository } from "../repositories/RecipeRepository.js";
/** /**
* Responsible for loading recipe header data * Responsible for loading recipe header data
*/ */
export class CompactRecipeHandler { export class CompactRecipeController {
constructor( constructor(
private repository: RecipeRepository, private repository: RecipeRepository,
private mapper: CompactRecipeDtoEntityMapper private mapper: CompactRecipeDtoEntityMapper
@ -25,19 +25,4 @@ export class CompactRecipeHandler {
}); });
return recipeDtos; 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);
}
}
} }

View file

@ -5,11 +5,12 @@ import { NotFoundError, ValidationError } from "../errors/httpErrors.js";
import { RecipeInstructionStepDto } from "../dtos/RecipeInstructionStepDto.js"; import { RecipeInstructionStepDto } from "../dtos/RecipeInstructionStepDto.js";
import { RecipeIngredientGroupDto } from "../dtos/RecipeIngredientGroupDto.js"; import { RecipeIngredientGroupDto } from "../dtos/RecipeIngredientGroupDto.js";
import { RecipeIngredientDto } from "../dtos/RecipeIngredientDto.js"; import { RecipeIngredientDto } from "../dtos/RecipeIngredientDto.js";
import { Entity } from "typeorm";
/** /**
* Controls all recipe specific actions * Controls all recipe specific actions
*/ */
export class RecipeHandler { export class RecipeController {
constructor( constructor(
private recipeRepository: RecipeRepository, private recipeRepository: RecipeRepository,
private mapper: RecipeDtoEntityMapper private mapper: RecipeDtoEntityMapper
@ -37,19 +38,9 @@ export class RecipeHandler {
if (!this.isRecipeDtoValid(dto)) { if (!this.isRecipeDtoValid(dto)) {
throw new ValidationError("recipe data is not valid!") throw new ValidationError("recipe data is not valid!")
} }
const recipeId = dto.id const recipeEntity = this.mapper.toEntity(dto);
if(recipeId === undefined){ // @todo doesn't create new ingredient groups, ingredients or instruction steps yet
throw new ValidationError("Trying to update recipe without ID!") const savedEntity = await this.recipeRepository.save(recipeEntity);
}
// 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); return this.mapper.toDto(savedEntity);
} }
/** /**

View file

@ -9,7 +9,7 @@ import { UUID } from "crypto";
/** /**
* Controls all user specific actions * Controls all user specific actions
*/ */
export class UserHandler { export class UserController {
constructor( constructor(
private userRepository: UserRepository, private userRepository: UserRepository,
private mapper: UserDtoEntityMapper private mapper: UserDtoEntityMapper

View file

@ -11,16 +11,9 @@ const __dirname = dirname(__filename);
dotenv.config(); dotenv.config();
/**
* Load config
*/
const { DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE, NODE_ENV } = const { DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE, NODE_ENV } =
process.env; process.env;
/**
* Configures data source
*/
export const AppDataSource = new DataSource({ export const AppDataSource = new DataSource({
type: "postgres", type: "postgres",
host: DB_HOST, host: DB_HOST,
@ -31,7 +24,7 @@ export const AppDataSource = new DataSource({
synchronize: NODE_ENV === "dev" ? false : false, synchronize: NODE_ENV === "dev" ? false : false,
//logging logs sql command on the terminal //logging logs sql command on the terminal
logging: NODE_ENV === "dev" ? ["query", "error"] : false, logging: NODE_ENV === "dev" ? false : false,
entities: [join(__dirname, "/entities/*.{js, ts}")], entities: [join(__dirname, "/entities/*.{js, ts}")],
migrations: [join(__dirname, "/migrations/*.js")], migrations: [join(__dirname, "/migrations/*.js")],
subscribers: [], subscribers: [],

View file

@ -1,3 +1,4 @@
import { UUID } from "crypto";
import { AbstractDto } from "./AbstractDto.js"; import { AbstractDto } from "./AbstractDto.js";
export class RecipeIngredientDto extends AbstractDto{ export class RecipeIngredientDto extends AbstractDto{
@ -6,5 +7,5 @@ export class RecipeIngredientDto extends AbstractDto{
amount?: number; amount?: number;
unit?: string; unit?: string;
sortOrder!: number; sortOrder!: number;
ingredientGroupId?: string; ingredientGroupId?: UUID;
} }

View file

@ -1,9 +1,10 @@
import { UUID } from "crypto";
import { AbstractDto } from "./AbstractDto.js"; import { AbstractDto } from "./AbstractDto.js";
import { RecipeIngredientDto } from "./RecipeIngredientDto.js"; import { RecipeIngredientDto } from "./RecipeIngredientDto.js";
export class RecipeIngredientGroupDto extends AbstractDto{ export class RecipeIngredientGroupDto extends AbstractDto{
title?: string; title?: string;
sortOrder!: number; sortOrder!: number;
recipeId?: string; recipeId?: UUID;
ingredients!: RecipeIngredientDto[]; ingredients!: RecipeIngredientDto[];
} }

View file

@ -1,5 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { AuthHandler } from "../handlers/AuthHandler.js"; import { AuthController } from "../controllers/AuthController.js";
import { UserRepository } from "../repositories/UserRepository.js"; import { UserRepository } from "../repositories/UserRepository.js";
import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js";
import { import {
@ -14,13 +14,8 @@ export const authBasicRoute = "/auth"
const router = Router(); const router = Router();
const userRepository = new UserRepository(); const userRepository = new UserRepository();
const mapper = new UserDtoEntityMapper(); const mapper = new UserDtoEntityMapper();
const authController = new AuthHandler(userRepository, mapper); const authController = new AuthController(userRepository, mapper);
/**
* Login using username and password
* Consumes LoginRequestDto
* Responds with LoginResponseDto
*/
router.post("/login", async (req, res) => { router.post("/login", async (req, res) => {
console.log("login point called") console.log("login point called")
try { try {

View file

@ -1,7 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
import { RecipeRepository } from "../repositories/RecipeRepository.js"; import { RecipeRepository } from "../repositories/RecipeRepository.js";
import { CompactRecipeHandler } from "../handlers/CompactRecipeHandler.js"; import { CompactRecipeController } from "../controllers/CompactRecipeController.js";
import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js"; import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js";
/** /**
@ -12,18 +12,14 @@ const router = Router();
// Inject repo + mapper here // Inject repo + mapper here
const recipeRepository = new RecipeRepository(); const recipeRepository = new RecipeRepository();
const compactRecipeMapper = new CompactRecipeDtoEntityMapper(); const compactRecipeMapper = new CompactRecipeDtoEntityMapper();
const compactRecipeHandler = new CompactRecipeHandler(recipeRepository, compactRecipeMapper); const compactRecipeController = new CompactRecipeController(recipeRepository, compactRecipeMapper);
/** /**
* Load header data of all recipes * Load header data of all recipes
* Responds with a list of CompactRecipeDtos
*/ */
router.get( router.get(
"/", "/",
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
// extract search string from query parameters, convert to lower case for case insensitive search const response = await compactRecipeController.getAllCompactRecipes();
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); res.status(201).json(response);
}) })
); );

View file

@ -1,7 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { RecipeRepository } from "../repositories/RecipeRepository.js"; import { RecipeRepository } from "../repositories/RecipeRepository.js";
import { RecipeDtoEntityMapper } from "../mappers/RecipeDtoEntityMapper.js"; import { RecipeDtoEntityMapper } from "../mappers/RecipeDtoEntityMapper.js";
import { RecipeHandler } from "../handlers/RecipeHandler.js"; import { RecipeController } from "../controllers/RecipeController.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
import { RecipeDto } from "../dtos/RecipeDto.js"; import { RecipeDto } from "../dtos/RecipeDto.js";
import { RecipeIngredientDtoEntityMapper } from "../mappers/RecipeIngredientDtoEntityMapper.js"; import { RecipeIngredientDtoEntityMapper } from "../mappers/RecipeIngredientDtoEntityMapper.js";
@ -20,12 +20,10 @@ const recipeIngredientMapper = new RecipeIngredientDtoEntityMapper();
const recipeIngredientGroupMapper = new RecipeIngredientGroupDtoEntityMapper(recipeIngredientMapper); const recipeIngredientGroupMapper = new RecipeIngredientGroupDtoEntityMapper(recipeIngredientMapper);
const recipeInstructionStepMapper = new RecipeInstructionStepDtoEntityMapper(); const recipeInstructionStepMapper = new RecipeInstructionStepDtoEntityMapper();
const recipeMapper = new RecipeDtoEntityMapper(recipeInstructionStepMapper, recipeIngredientGroupMapper); const recipeMapper = new RecipeDtoEntityMapper(recipeInstructionStepMapper, recipeIngredientGroupMapper);
const recipeController = new RecipeHandler(recipeRepository, recipeMapper); const recipeController = new RecipeController(recipeRepository, recipeMapper);
/** /**
* Create new recipe * Create new recipe
* Consumes: RecipeDto
* Responds with RecipeDto
*/ */
router.post( router.post(
"/", "/",
@ -36,10 +34,6 @@ router.post(
}) })
); );
/**
* Load recipe by id
* Responds with RecipeDto
*/
router.get( router.get(
"/:id", "/:id",
asyncHandler(async(req, res) => { asyncHandler(async(req, res) => {
@ -49,12 +43,6 @@ router.get(
}) })
); );
/**
* Saves existing recipe
* Also handles changes to instructions steps and ingredient (groups)
* Consumes: RecipeDto
* Responds with RecipeDto
*/
router.put( router.put(
"/:id", "/:id",
asyncHandler(async(req, res) =>{ asyncHandler(async(req, res) =>{

View file

@ -1,5 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { UserHandler } from "../handlers/UserHandler.js"; import { UserController } from "../controllers/UserController.js";
import { CreateUserRequestDto } from "../dtos/CreateUserRequestDto.js"; import { CreateUserRequestDto } from "../dtos/CreateUserRequestDto.js";
import { UserRepository } from "../repositories/UserRepository.js"; import { UserRepository } from "../repositories/UserRepository.js";
import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js";
@ -13,12 +13,10 @@ const router = Router();
// Inject repo + mapper here // Inject repo + mapper here
const userRepository = new UserRepository(); const userRepository = new UserRepository();
const userMapper = new UserDtoEntityMapper(); const userMapper = new UserDtoEntityMapper();
const userController = new UserHandler(userRepository, userMapper); const userController = new UserController(userRepository, userMapper);
/** /**
* Create a new user * Create a new user
* Consumes CreateUserRequestDto
* Responds with UserDto
*/ */
router.post( router.post(
"/", "/",
@ -31,7 +29,6 @@ router.post(
/** /**
* Get user data for current user * Get user data for current user
* Responds with UserDto
*/ */
router.get("/me", router.get("/me",
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {

View file

@ -4,9 +4,6 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from "typeorm"; } from "typeorm";
/**
* Abstract entity containing basic fields that all entities have in common
*/
export abstract class AbstractEntity { export abstract class AbstractEntity {
@PrimaryGeneratedColumn("uuid") @PrimaryGeneratedColumn("uuid")
id?: string; id?: string;

View file

@ -19,13 +19,13 @@ export class RecipeEntity extends AbstractEntity {
// make sure not to induce a circular dependency! user arrow function without brackets! // make sure not to induce a circular dependency! user arrow function without brackets!
@OneToMany(() => RecipeInstructionStepEntity, (instructionStep) => instructionStep.recipe, { @OneToMany(() => RecipeInstructionStepEntity, (instructionStep) => instructionStep.recipe, {
cascade: true cascade: true,
}) })
instructionSteps!: RecipeInstructionStepEntity[]; instructionSteps!: RecipeInstructionStepEntity[];
// make sure not to induce a circular dependency! user arrow function without brackets! // make sure not to induce a circular dependency! user arrow function without brackets!
@OneToMany(() => RecipeIngredientGroupEntity, (ingredientGroup) => ingredientGroup.recipe, { @OneToMany(() => RecipeIngredientGroupEntity, (ingredientGroup) => ingredientGroup.recipe, {
cascade: true cascade: true,
}) })
ingredientGroups!: Relation<RecipeIngredientGroupEntity>[]; ingredientGroups!: Relation<RecipeIngredientGroupEntity>[];
} }

View file

@ -24,11 +24,7 @@ export class RecipeIngredientEntity extends AbstractEntity {
@JoinColumn({name: "recipe_ingredient_group_id"}) @JoinColumn({name: "recipe_ingredient_group_id"})
@ManyToOne(() => RecipeIngredientGroupEntity, (ingredientGroup) => ingredientGroup.ingredients, @ManyToOne(() => RecipeIngredientGroupEntity, (ingredientGroup) => ingredientGroup.ingredients,
{ {onDelete: "CASCADE", nullable: false}
onDelete: "CASCADE",
nullable: false,
orphanedRowAction: "delete"
}
) )
ingredientGroup!: Relation<RecipeIngredientGroupEntity>; ingredientGroup!: Relation<RecipeIngredientGroupEntity>;
} }

View file

@ -16,16 +16,12 @@ export class RecipeIngredientGroupEntity extends AbstractEntity {
@JoinColumn({name: "recipe_id"}) @JoinColumn({name: "recipe_id"})
@ManyToOne(() => RecipeEntity, (recipe) => recipe.ingredientGroups, @ManyToOne(() => RecipeEntity, (recipe) => recipe.ingredientGroups,
{ {onDelete: "CASCADE", nullable: false}
onDelete: "CASCADE",
nullable: false,
orphanedRowAction: "delete" // delete removed groups
}
) )
recipe!: Relation<RecipeEntity>; recipe!: Relation<RecipeEntity>;
@OneToMany(() => RecipeIngredientEntity, (ingredient) => ingredient.ingredientGroup, { @OneToMany(() => RecipeIngredientEntity, (ingredient) => ingredient.ingredientGroup, {
cascade: true cascade: true,
}) })
ingredients!: Relation<RecipeIngredientEntity>[]; ingredients!: Relation<RecipeIngredientEntity>[];

View file

@ -15,11 +15,7 @@ export class RecipeInstructionStepEntity extends AbstractEntity {
@JoinColumn({name: "recipe_id"}) @JoinColumn({name: "recipe_id"})
@ManyToOne(() => RecipeEntity, (recipe) => recipe.instructionSteps, @ManyToOne(() => RecipeEntity, (recipe) => recipe.instructionSteps,
{ {onDelete: "CASCADE", nullable: false}
onDelete: "CASCADE",
nullable: false,
orphanedRowAction: "delete" // delete removed groups
}
) )
recipe!: Relation<RecipeEntity>; recipe!: Relation<RecipeEntity>;

View file

@ -1,9 +1,7 @@
import { Entity, Column } from "typeorm"; import { Entity, Column } from "typeorm";
import { AbstractEntity } from "./AbstractEntity.js"; import { AbstractEntity } from "./AbstractEntity.js";
/** // @todo Add migration to update table
* Entity describing a user
*/
@Entity({ name: "user" }) @Entity({ name: "user" })
export class UserEntity extends AbstractEntity { export class UserEntity extends AbstractEntity {
@Column({ nullable: false, name: "user_name" }) @Column({ nullable: false, name: "user_name" })

View file

@ -27,21 +27,6 @@ async function startServer() {
await AppDataSource.runMigrations(); await AppDataSource.runMigrations();
console.log("Migrations executed"); 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 // Activate Authentication
app.use(authentication); app.use(authentication);

View file

@ -25,57 +25,7 @@ export abstract class AbstractDtoEntityMapper<
return entity; return entity;
} }
/**
* 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]) ?? []);
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;
}
// Abstract methods to be implemented by subclasses // 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; abstract toDto(entity: E): D;
/**
* Maps a DTO to entity
* @param dto DTO to map to entity
*/
abstract toEntity(dto: D): E; 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;
} }

View file

@ -17,16 +17,4 @@ export class CompactRecipeDtoEntityMapper extends AbstractDtoEntityMapper<Recipe
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!"); throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
} }
createNewEntity() : RecipeEntity {
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
}
mergeDtoIntoEntity(dto: CompactRecipeDto, entity: RecipeEntity): RecipeEntity {
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
}
mergeDtoListIntoEntityList(dtos: CompactRecipeDto[], entities: RecipeEntity[]) : RecipeEntity[]{
throw new Error("Mapping CompactRecipeDto to RecipeEntity is not allowed!");
}
} }

View file

@ -21,12 +21,14 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
dto.amountDescription = entity.amountDescription; dto.amountDescription = entity.amountDescription;
// map instructions // map instructions
dto.instructions = entity.instructionSteps.map((stepEntity) => this.instructionStepMapper.toDto(stepEntity)); const instructionStepEntities = entity.instructionSteps;
// @todo map ids dto.instructions.forEach(step => step.recipeId = entity.id); // set recipe relation explicitly! const instructionStepDtos = instructionStepEntities.map((stepEntity) => this.instructionStepMapper.toDto(stepEntity));
dto.instructions = instructionStepDtos;
// map ingredient groups // map ingredient groups
dto.ingredientGroups = entity.ingredientGroups.map((groupEntity) => this.ingredientGroupMapper.toDto(groupEntity)); const ingredientGroupEntities = entity.ingredientGroups;
const ingredientGroupDtos = ingredientGroupEntities.map((groupEntity) => this.ingredientGroupMapper.toDto(groupEntity));
dto.ingredientGroups = ingredientGroupDtos;
return dto; return dto;
} }
@ -40,47 +42,16 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
entity.amountDescription = dto.amountDescription; entity.amountDescription = dto.amountDescription;
// map instructions // map instructions
entity.instructionSteps = dto.instructions.map((stepDto) => { const instructionStepDtos = dto.instructions;
const stepEntity = this.instructionStepMapper.toEntity(stepDto); const instructionStepEntities = instructionStepDtos.map((stepDto) => this.instructionStepMapper.toEntity(stepDto));
entity.instructionSteps = instructionStepEntities;
// 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 // map ingredient groups
entity.ingredientGroups = dto.ingredientGroups.map((groupDto) => { const ingredientGroupDtos = dto.ingredientGroups;
const groupEntity = this.ingredientGroupMapper.toEntity(groupDto); const ingredientGroupEntities = ingredientGroupDtos.map((ingredientGroupDto) => this.ingredientGroupMapper.toEntity(ingredientGroupDto));
groupEntity.recipe = entity; entity.ingredientGroups = ingredientGroupEntities;
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();
}
} }

View file

@ -29,17 +29,4 @@ export class RecipeIngredientDtoEntityMapper extends AbstractDtoEntityMapper<Rec
return entity; return entity;
} }
createNewEntity(): RecipeIngredientEntity {
return new RecipeIngredientEntity();
}
mergeDtoIntoEntity(dto: RecipeIngredientDto, entity: RecipeIngredientEntity): RecipeIngredientEntity {
entity.name = dto.name;
entity.amount = dto.amount;
entity.unit = dto.unit;
entity.subtext = dto.subtext;
entity.sortOrder = dto.sortOrder;
return entity;
}
} }

View file

@ -3,10 +3,10 @@ import { RecipeIngredientGroupEntity } from "../entities/RecipeIngredientGroupEn
import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js"; import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js";
import { RecipeIngredientDtoEntityMapper } from "./RecipeIngredientDtoEntityMapper.js"; import { RecipeIngredientDtoEntityMapper } from "./RecipeIngredientDtoEntityMapper.js";
export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMapper<RecipeIngredientGroupEntity, RecipeIngredientGroupDto> { export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMapper<RecipeIngredientGroupEntity,RecipeIngredientGroupDto>{
constructor( constructor(
private ingredientMapper: RecipeIngredientDtoEntityMapper private ingredientMapper : RecipeIngredientDtoEntityMapper
) { ){
super(); super();
} }
@ -18,7 +18,9 @@ export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMappe
dto.sortOrder = entity.sortOrder dto.sortOrder = entity.sortOrder
// map ingredients // map ingredients
dto.ingredients = entity.ingredients?.map((ingredientEntity) => this.ingredientMapper.toDto(ingredientEntity)); const ingredientEntities = entity.ingredients;
const ingredientDtos = ingredientEntities?.map((ingredientEntity) => this.ingredientMapper.toDto(ingredientEntity));
dto.ingredients = ingredientDtos;
return dto; return dto;
} }
@ -31,32 +33,9 @@ export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMappe
entity.sortOrder = dto.sortOrder entity.sortOrder = dto.sortOrder
// map ingredients // map ingredients
entity.ingredients = dto.ingredients.map((ingredientDto) => { const ingredientDtos = dto.ingredients;
const ingredientEntity = this.ingredientMapper.toEntity(ingredientDto); const ingredientEntities = ingredientDtos?.map((ingredientDto) => this.ingredientMapper.toEntity(ingredientDto));
ingredientEntity.ingredientGroup = entity; entity.ingredients = ingredientEntities;
// 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; return entity;
} }

View file

@ -24,14 +24,4 @@ export class RecipeInstructionStepDtoEntityMapper extends AbstractDtoEntityMappe
return entity; return entity;
} }
createNewEntity(): RecipeInstructionStepEntity {
return new RecipeInstructionStepEntity();
}
mergeDtoIntoEntity(dto: RecipeInstructionStepDto, entity: RecipeInstructionStepEntity): RecipeInstructionStepEntity {
entity.text = dto.text;
entity.sortOrder = dto.sortOrder;
return entity;
}
} }

View file

@ -20,16 +20,6 @@ export class UserDtoEntityMapper extends AbstractDtoEntityMapper<UserEntity, Use
const entity = new UserEntity(); const entity = new UserEntity();
this.mapBaseDtoToEntity(dto, entity); this.mapBaseDtoToEntity(dto, entity);
this.mergeDtoIntoEntity(dto, entity)
return entity;
}
createNewEntity(): UserEntity {
return new UserEntity;
}
mergeDtoIntoEntity(dto: UserDto, entity: UserEntity): UserEntity {
entity.userName = dto.userName; entity.userName = dto.userName;
entity.email = dto.email; entity.email = dto.email;
entity.firstName = dto.firstName; entity.firstName = dto.firstName;

View file

@ -2,9 +2,6 @@ import { Repository, DeepPartial } from "typeorm";
import { AppDataSource } from "../data-source.js"; import { AppDataSource } from "../data-source.js";
import { AbstractEntity } from "../entities/AbstractEntity.js"; import { AbstractEntity } from "../entities/AbstractEntity.js";
/**
* Basic methods for saving, loading and deleting data
*/
export abstract class AbstractRepository<T extends AbstractEntity> { export abstract class AbstractRepository<T extends AbstractEntity> {
protected repo: Repository<T>; protected repo: Repository<T>;
@ -25,11 +22,20 @@ export abstract class AbstractRepository<T extends AbstractEntity> {
return this.repo.save(entity); return this.repo.save(entity);
} }
/* async update(id: string, partialData: DeepPartial<T>): Promise<T> {
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<void> { async delete(id: string): Promise<void> {
await this.repo.delete(id as any); await this.repo.delete(id as any);
} }
async update(entity: T): Promise<T> { async save(entity: T): Promise<T> {
return this.repo.save(entity); return this.repo.save(entity);
} }

View file

@ -1,7 +1,5 @@
import { AbstractRepository } from "./AbstractRepository.js"; import { AbstractRepository } from "./AbstractRepository.js";
import { RecipeEntity } from "../entities/RecipeEntity.js"; import { RecipeEntity } from "../entities/RecipeEntity.js";
import { AppDataSource } from "../data-source.js";
import { ILike, Like } from "typeorm";
export class RecipeRepository extends AbstractRepository<RecipeEntity> { export class RecipeRepository extends AbstractRepository<RecipeEntity> {
constructor() { constructor() {
@ -22,37 +20,6 @@ export class RecipeRepository extends AbstractRepository<RecipeEntity> {
'instructionSteps' 'instructionSteps'
] ]
}); });
} }
/**
* 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<RecipeEntity[] | null>{
// @todo doesn't work like expected...
return this.repo.find(
{ where: {title: ILike(`%${searchString}%`)}}
);
}
/**
* Update recipe and relations
* @param entity Updated entity
* @returns Updated Entity
*/
async updateRecipe(entity: RecipeEntity): Promise<RecipeEntity> {
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);
});
}
} }