Implement compact-recipe/list-by-filter

This commit is contained in:
araemer 2026-02-27 19:52:05 +01:00
parent 8ae6548dec
commit c944b5c6b7
21 changed files with 151 additions and 76 deletions

View file

@ -1,5 +1,5 @@
meta { meta {
name: AuthPoint name: AuthRestResource
seq: 2 seq: 2
} }

View file

@ -1,5 +1,5 @@
meta { meta {
name: CompactRecipePoint name: CompactRecipeRestResource
seq: 4 seq: 4
} }

View file

@ -1,19 +0,0 @@
meta {
name: getCompactRecipes
type: http
seq: 2
}
get {
url: {{url}}/compact-recipe?search=kuchen
body: none
auth: inherit
}
params:query {
search: kuchen
}
settings {
encodeUrl: true
}

View file

@ -0,0 +1,22 @@
meta {
name: getListByFilter
type: http
seq: 2
}
get {
url: {{url}}/compact-recipe/list-by-filter
body: json
auth: inherit
}
body:json {
{
"searchString": "kuchen",
"tagIdList": ["7200f6e8-3cd0-439e-a395-d7ed43e29a3e"]
}
}
settings {
encodeUrl: true
}

View file

@ -1,5 +1,5 @@
meta { meta {
name: RecipePoint name: RecipeRestResource
seq: 3 seq: 3
} }

View file

@ -12,7 +12,7 @@ post {
body:json { body:json {
{ {
"description": "Kuchen" "description": "Salat"
} }
} }

View file

@ -1,5 +1,5 @@
meta { meta {
name: UserPoint name: UserRestResource
seq: 5 seq: 5
} }

View file

@ -10,6 +10,9 @@ script:pre-request {
try{ try{
// An dieser Stelle muss überprüft werden, ob diese Funktion gerade aufgerufen wird, ansonsten entsteht eine Endlosschleife. // An dieser Stelle muss überprüft werden, ob diese Funktion gerade aufgerufen wird, ansonsten entsteht eine Endlosschleife.
const blocked = bru.getEnvVar("blocked"); const blocked = bru.getEnvVar("blocked");
console.log("blocked", blocked)
console.log("current date", new Date())
console.log("expire date", bru.getEnvVar("tokenExpireDate"))
if(blocked === "false" && new Date().valueOf() > Number(bru.getEnvVar("tokenExpireDate"))){ if(blocked === "false" && new Date().valueOf() > Number(bru.getEnvVar("tokenExpireDate"))){
console.log('new Session') console.log('new Session')

View file

@ -0,0 +1,13 @@
/**
* Request wrapper for searching recipes based on a filter
*/
export class CompactRecipeFilterRequest {
/**
* Search string applied to the recipe title
*/
searchString?: string;
/**
* List of tags that must be applied to the recipe
*/
tagIdList?: string[];
}

View file

@ -0,0 +1,8 @@
import {CompactRecipeDto} from "./CompactRecipeDto.js";
/**
* Filter response containing a list of all recipes matching the search
*/
export class CompactRecipeFilterResponse {
compactRecipeList! : CompactRecipeDto[];
}

View file

@ -4,6 +4,8 @@ import { RecipeRepository } from "../../repositories/RecipeRepository.js";
import { CompactRecipeHandler } from "../../handlers/CompactRecipeHandler.js"; import { CompactRecipeHandler } from "../../handlers/CompactRecipeHandler.js";
import { CompactRecipeDtoEntityMapper } from "../../mappers/CompactRecipeDtoEntityMapper.js"; import { CompactRecipeDtoEntityMapper } from "../../mappers/CompactRecipeDtoEntityMapper.js";
import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js";
import {CompactRecipeFilterRequest} from "../dtos/CompactRecipeFilterRequest.js";
import {CompactRecipeFilterResponse} from "../dtos/CompactRecipeFilterResponse.js";
export const compactRecipeBasicRoute = "/compact-recipe" export const compactRecipeBasicRoute = "/compact-recipe"
/** /**
@ -16,8 +18,6 @@ const compactRecipeHandler = new CompactRecipeHandler(new RecipeRepository(), ne
/** /**
* Load header data of all recipes * Load header data of all recipes
* Responds with a list of CompactRecipeDtos * Responds with a list of CompactRecipeDtos
* @todo request wrapper DTO
* @todo response wrapper DTO
*/ */
router.get( router.get(
"/", "/",
@ -30,4 +30,12 @@ router.get(
}) })
); );
router.get(
"/list-by-filter",
asyncHandler(async (req , res) => {
const request : CompactRecipeFilterRequest = req.body;
const response : CompactRecipeFilterResponse = await compactRecipeHandler.getRecipesByFilter(request);
res.status(HttpStatusCode.OK).json(response);
})
)
export default router; export default router;

View file

@ -2,6 +2,8 @@ import { CompactRecipeDto } from "../api/dtos/CompactRecipeDto.js";
import { RecipeEntity } from "../entities/RecipeEntity.js"; import { RecipeEntity } from "../entities/RecipeEntity.js";
import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js"; import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js";
import { RecipeRepository } from "../repositories/RecipeRepository.js"; import { RecipeRepository } from "../repositories/RecipeRepository.js";
import {CompactRecipeFilterRequest} from "../api/dtos/CompactRecipeFilterRequest.js";
import {CompactRecipeFilterResponse} from "../api/dtos/CompactRecipeFilterResponse.js";
/** /**
* Responsible for loading recipe header data * Responsible for loading recipe header data
@ -18,7 +20,6 @@ export class CompactRecipeHandler {
*/ */
async getAllCompactRecipes() { async getAllCompactRecipes() {
const recipeEntities: RecipeEntity[] = await this.repository.findAll(); const recipeEntities: RecipeEntity[] = await this.repository.findAll();
// @todo load instruction steps, ingredient groups and ingredients before mapping!
let recipeDtos: CompactRecipeDto[] = []; let recipeDtos: CompactRecipeDto[] = [];
recipeEntities.forEach(recipeEntity => { recipeEntities.forEach(recipeEntity => {
recipeDtos.push(this.mapper.toDto(recipeEntity)); recipeDtos.push(this.mapper.toDto(recipeEntity));
@ -38,7 +39,23 @@ export class CompactRecipeHandler {
// get all // get all
return this.getAllCompactRecipes(); return this.getAllCompactRecipes();
} else { } else {
return this.repository.findCompactRecipeBySearch(searchString); const tagIdList : string[] = [];
return this.repository.findCompactRecipeByFilter(searchString, tagIdList);
} }
} }
async getRecipesByFilter(request: CompactRecipeFilterRequest) : Promise<CompactRecipeFilterResponse> {
const searchString = request.searchString;
const tagIdList = request.tagIdList;
var recipeEntities : RecipeEntity[] = await this.repository.findCompactRecipeByFilter(searchString, tagIdList);
const response = new CompactRecipeFilterResponse();
// @todo move list mapping to mapper
let recipeDtos: CompactRecipeDto[] = [];
// Add mapper function to map the result list.
recipeEntities.forEach(recipeEntity => {
recipeDtos.push(this.mapper.toDto(recipeEntity));
});
response.compactRecipeList = recipeDtos;
return response;
}
} }

View file

@ -1,59 +1,82 @@
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 { AppDataSource } from "../data-source.js";
import { ILike } from "typeorm"; import { UUID } from "crypto";
export class RecipeRepository extends AbstractRepository<RecipeEntity> { export class RecipeRepository extends AbstractRepository<RecipeEntity> {
constructor() { constructor() {
super(RecipeEntity); super(RecipeEntity);
} }
/** /**
* Find recipe including all relations by id * Find recipe including all relations by id
* @param id Recipe id * @param id Recipe id
* @returns RecipeEntity including all relations such as ingredients groups, ingredients and instruction steps * @returns RecipeEntity including all relations such as ingredients groups, ingredients and instruction steps
*/ */
async findById(id: string): Promise<RecipeEntity | null> { async findById(id: string): Promise<RecipeEntity | null> {
return this.repo.findOne( return this.repo.findOne({
{ where: { id } as any, where: { id } as any,
relations: [ relations: [
'ingredientGroups', "ingredientGroups",
'ingredientGroups.ingredients', "ingredientGroups.ingredients",
'instructionSteps', "instructionSteps",
'tagList', "tagList",
] ],
}); });
} }
/** /**
* Find all recipes matching the search. Currently it only searches on the title. Fetches only recipe header data but no relations. * 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 * @param searchString String to search for. Might be undefined if no search is applied (all recipes or filter for tags only)
* @returns List of recipe entities matching the search criteria * @param tagIdList List of tags. The recipe must have all the tags to be taken into account. May be undefined if not filtering for tags
*/ * @returns List of recipe entities matching the filter criteria
async findCompactRecipeBySearch(searchString : string): Promise<RecipeEntity[] | null>{ */
// @todo doesn't work like expected... async findCompactRecipeByFilter(
return this.repo.find( searchString: string | undefined,
{ where: {title: ILike(`%${searchString}%`)}} tagIdList: string[] | UUID[] | undefined
); ): Promise<RecipeEntity[]> {
} const qb = this.repo.createQueryBuilder("recipe");
/** // Filter by title if a search string is provided
* Update recipe and relations if (searchString !== undefined && searchString.length > 0) {
* @param entity Updated entity qb.andWhere("recipe.title ILIKE :title", { title: `%${searchString}%` });
* @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", "tagList"],
});
// merge new entity and existing entity
this.repo.merge(existing, entity);
return this.repo.save(existing);
});
}
// Filter by tags: the recipe must have ALL tags in the list
if (tagIdList !== undefined && tagIdList.length > 0) {
// Join recipe_tag for each required tag individually so that only
// recipes possessing every tag are returned (AND semantics, not OR)
tagIdList.forEach((tagId, index) => {
const alias = `tag_${index}`;
const param = `tagId_${index}`;
qb.innerJoin(
"recipe.tagList",
alias,
`${alias}.id = :${param}`,
{ [param]: tagId }
);
});
}
return qb.getMany();
}
/**
* 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", "tagList"],
});
// merge new entity and existing entity
this.repo.merge(existing, entity);
return this.repo.save(existing);
});
}
} }