Compare commits
4 commits
e33dfdb845
...
760c91af56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
760c91af56 | ||
|
|
7ec4324fde | ||
|
|
7e831cfb64 | ||
|
|
b1b714f44e |
29 changed files with 337 additions and 77 deletions
|
|
@ -11,7 +11,7 @@ get {
|
||||||
}
|
}
|
||||||
|
|
||||||
auth:bearer {
|
auth:bearer {
|
||||||
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1ODk4Njk4MSwiZXhwIjoxNzU5MDczMzgxfQ.rYvECzhI3Tptse3yVjZvR9RXgs1gkwAt2_5-hpAXvB0
|
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTA3NjA2NCwiZXhwIjoxNzU5MTYyNDY0fQ.exf_3fCrarW0LhUqPuadvp89BOUazEXtdSTkGDIAU_Q
|
||||||
}
|
}
|
||||||
|
|
||||||
settings {
|
settings {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ put {
|
||||||
}
|
}
|
||||||
|
|
||||||
auth:bearer {
|
auth:bearer {
|
||||||
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTA3NjA2NCwiZXhwIjoxNzU5MTYyNDY0fQ.exf_3fCrarW0LhUqPuadvp89BOUazEXtdSTkGDIAU_Q
|
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc1OTE3MjI3MywiZXhwIjoxNzU5MjU4NjczfQ._X_ZtBGtx0_14Nx90ctSQL-ieVPptaPc7WjG3FnyOOA
|
||||||
}
|
}
|
||||||
|
|
||||||
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,6 +57,9 @@ 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": [
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,16 @@ 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,
|
||||||
|
|
@ -24,7 +31,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" ? false : false,
|
logging: NODE_ENV === "dev" ? ["query", "error"] : 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: [],
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { UUID } from "crypto";
|
|
||||||
import { AbstractDto } from "./AbstractDto.js";
|
import { AbstractDto } from "./AbstractDto.js";
|
||||||
|
|
||||||
export class RecipeIngredientDto extends AbstractDto{
|
export class RecipeIngredientDto extends AbstractDto{
|
||||||
|
|
@ -7,5 +6,5 @@ export class RecipeIngredientDto extends AbstractDto{
|
||||||
amount?: number;
|
amount?: number;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
sortOrder!: number;
|
sortOrder!: number;
|
||||||
ingredientGroupId?: UUID;
|
ingredientGroupId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
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?: UUID;
|
recipeId?: string;
|
||||||
ingredients!: RecipeIngredientDto[];
|
ingredients!: RecipeIngredientDto[];
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { AuthController } from "../controllers/AuthController.js";
|
import { AuthHandler } from "../handlers/AuthHandler.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,8 +14,13 @@ 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 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) => {
|
router.post("/login", async (req, res) => {
|
||||||
console.log("login point called")
|
console.log("login point called")
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -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 { CompactRecipeController } from "../controllers/CompactRecipeController.js";
|
import { CompactRecipeHandler } from "../handlers/CompactRecipeHandler.js";
|
||||||
import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js";
|
import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -12,14 +12,18 @@ 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 compactRecipeController = new CompactRecipeController(recipeRepository, compactRecipeMapper);
|
const compactRecipeHandler = new CompactRecipeHandler(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) => {
|
||||||
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() : "";
|
||||||
|
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);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 { RecipeController } from "../controllers/RecipeController.js";
|
import { RecipeHandler } from "../handlers/RecipeHandler.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,10 +20,12 @@ 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 RecipeController(recipeRepository, recipeMapper);
|
const recipeController = new RecipeHandler(recipeRepository, recipeMapper);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new recipe
|
* Create new recipe
|
||||||
|
* Consumes: RecipeDto
|
||||||
|
* Responds with RecipeDto
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
|
|
@ -34,6 +36,10 @@ router.post(
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load recipe by id
|
||||||
|
* Responds with RecipeDto
|
||||||
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
asyncHandler(async(req, res) => {
|
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(
|
router.put(
|
||||||
"/:id",
|
"/:id",
|
||||||
asyncHandler(async(req, res) =>{
|
asyncHandler(async(req, res) =>{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { UserController } from "../controllers/UserController.js";
|
import { UserHandler } from "../handlers/UserHandler.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,10 +13,12 @@ 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 UserController(userRepository, userMapper);
|
const userController = new UserHandler(userRepository, userMapper);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new user
|
* Create a new user
|
||||||
|
* Consumes CreateUserRequestDto
|
||||||
|
* Responds with UserDto
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
|
|
@ -29,6 +31,7 @@ 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) => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -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>[];
|
||||||
}
|
}
|
||||||
|
|
@ -24,7 +24,11 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|
@ -16,12 +16,16 @@ 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>[];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ 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>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
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" })
|
||||||
|
|
|
||||||
|
|
@ -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 AuthController {
|
export class AuthHandler {
|
||||||
constructor(
|
constructor(
|
||||||
private userRepository: UserRepository,
|
private userRepository: UserRepository,
|
||||||
private mapper: UserDtoEntityMapper
|
private mapper: UserDtoEntityMapper
|
||||||
|
|
@ -6,7 +6,7 @@ import { RecipeRepository } from "../repositories/RecipeRepository.js";
|
||||||
/**
|
/**
|
||||||
* Responsible for loading recipe header data
|
* Responsible for loading recipe header data
|
||||||
*/
|
*/
|
||||||
export class CompactRecipeController {
|
export class CompactRecipeHandler {
|
||||||
constructor(
|
constructor(
|
||||||
private repository: RecipeRepository,
|
private repository: RecipeRepository,
|
||||||
private mapper: CompactRecipeDtoEntityMapper
|
private mapper: CompactRecipeDtoEntityMapper
|
||||||
|
|
@ -25,4 +25,19 @@ export class CompactRecipeController {
|
||||||
});
|
});
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,12 +5,11 @@ 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 RecipeController {
|
export class RecipeHandler {
|
||||||
constructor(
|
constructor(
|
||||||
private recipeRepository: RecipeRepository,
|
private recipeRepository: RecipeRepository,
|
||||||
private mapper: RecipeDtoEntityMapper
|
private mapper: RecipeDtoEntityMapper
|
||||||
|
|
@ -38,9 +37,19 @@ export class RecipeController {
|
||||||
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 recipeEntity = this.mapper.toEntity(dto);
|
const recipeId = dto.id
|
||||||
// @todo doesn't create new ingredient groups, ingredients or instruction steps yet
|
if(recipeId === undefined){
|
||||||
const savedEntity = await this.recipeRepository.save(recipeEntity);
|
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);
|
return this.mapper.toDto(savedEntity);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|
@ -9,7 +9,7 @@ import { UUID } from "crypto";
|
||||||
/**
|
/**
|
||||||
* Controls all user specific actions
|
* Controls all user specific actions
|
||||||
*/
|
*/
|
||||||
export class UserController {
|
export class UserHandler {
|
||||||
constructor(
|
constructor(
|
||||||
private userRepository: UserRepository,
|
private userRepository: UserRepository,
|
||||||
private mapper: UserDtoEntityMapper
|
private mapper: UserDtoEntityMapper
|
||||||
15
src/index.ts
15
src/index.ts
|
|
@ -27,6 +27,21 @@ 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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,57 @@ export abstract class AbstractDtoEntityMapper<
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Abstract methods to be implemented by subclasses
|
/**
|
||||||
abstract toDto(entity: E): D;
|
* Merge entity list with changes contained in DTO list
|
||||||
abstract toEntity(dto: D): E;
|
* @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
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,16 @@ 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!");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -21,14 +21,12 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
|
||||||
dto.amountDescription = entity.amountDescription;
|
dto.amountDescription = entity.amountDescription;
|
||||||
|
|
||||||
// map instructions
|
// map instructions
|
||||||
const instructionStepEntities = entity.instructionSteps;
|
dto.instructions = entity.instructionSteps.map((stepEntity) => this.instructionStepMapper.toDto(stepEntity));
|
||||||
const instructionStepDtos = instructionStepEntities.map((stepEntity) => this.instructionStepMapper.toDto(stepEntity));
|
// @todo map ids dto.instructions.forEach(step => step.recipeId = entity.id); // set recipe relation explicitly!
|
||||||
dto.instructions = instructionStepDtos;
|
|
||||||
|
|
||||||
// map ingredient groups
|
// map ingredient groups
|
||||||
const ingredientGroupEntities = entity.ingredientGroups;
|
dto.ingredientGroups = entity.ingredientGroups.map((groupEntity) => this.ingredientGroupMapper.toDto(groupEntity));
|
||||||
const ingredientGroupDtos = ingredientGroupEntities.map((groupEntity) => this.ingredientGroupMapper.toDto(groupEntity));
|
|
||||||
dto.ingredientGroups = ingredientGroupDtos;
|
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
@ -42,16 +40,47 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
|
||||||
entity.amountDescription = dto.amountDescription;
|
entity.amountDescription = dto.amountDescription;
|
||||||
|
|
||||||
// map instructions
|
// map instructions
|
||||||
const instructionStepDtos = dto.instructions;
|
entity.instructionSteps = dto.instructions.map((stepDto) => {
|
||||||
const instructionStepEntities = instructionStepDtos.map((stepDto) => this.instructionStepMapper.toEntity(stepDto));
|
const stepEntity = 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
|
||||||
const ingredientGroupDtos = dto.ingredientGroups;
|
entity.ingredientGroups = dto.ingredientGroups.map((groupDto) => {
|
||||||
const ingredientGroupEntities = ingredientGroupDtos.map((ingredientGroupDto) => this.ingredientGroupMapper.toEntity(ingredientGroupDto));
|
const groupEntity = this.ingredientGroupMapper.toEntity(groupDto);
|
||||||
entity.ingredientGroups = ingredientGroupEntities;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -29,4 +29,17 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -18,9 +18,7 @@ export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMappe
|
||||||
dto.sortOrder = entity.sortOrder
|
dto.sortOrder = entity.sortOrder
|
||||||
|
|
||||||
// map ingredients
|
// map ingredients
|
||||||
const ingredientEntities = entity.ingredients;
|
dto.ingredients = entity.ingredients?.map((ingredientEntity) => this.ingredientMapper.toDto(ingredientEntity));
|
||||||
const ingredientDtos = ingredientEntities?.map((ingredientEntity) => this.ingredientMapper.toDto(ingredientEntity));
|
|
||||||
dto.ingredients = ingredientDtos;
|
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
@ -33,9 +31,32 @@ export class RecipeIngredientGroupDtoEntityMapper extends AbstractDtoEntityMappe
|
||||||
entity.sortOrder = dto.sortOrder
|
entity.sortOrder = dto.sortOrder
|
||||||
|
|
||||||
// map ingredients
|
// map ingredients
|
||||||
const ingredientDtos = dto.ingredients;
|
entity.ingredients = dto.ingredients.map((ingredientDto) => {
|
||||||
const ingredientEntities = ingredientDtos?.map((ingredientDto) => this.ingredientMapper.toEntity(ingredientDto));
|
const ingredientEntity = this.ingredientMapper.toEntity(ingredientDto);
|
||||||
entity.ingredients = ingredientEntities;
|
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;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,14 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +20,16 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ 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>;
|
||||||
|
|
||||||
|
|
@ -22,20 +25,11 @@ 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 save(entity: T): Promise<T> {
|
async update(entity: T): Promise<T> {
|
||||||
return this.repo.save(entity);
|
return this.repo.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
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() {
|
||||||
|
|
@ -20,6 +22,37 @@ 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue