Add routes and fix mappers and migration scripts

This commit is contained in:
araemer 2026-02-22 08:26:20 +01:00
parent 58a581fbac
commit 801739ea30
11 changed files with 184 additions and 97 deletions

View file

@ -2,6 +2,7 @@
import { AbstractDto } from "./AbstractDto.js";
import { RecipeIngredientGroupDto } from "./RecipeIngredientGroupDto.js";
import { RecipeInstructionStepDto } from "./RecipeInstructionStepDto.js";
import {TagDto} from "./TagDto.js";
/**
* DTO describing a recipe
*/
@ -12,4 +13,5 @@ export class RecipeDto extends AbstractDto {
amountDescription?: string;
instructions!: RecipeInstructionStepDto[];
ingredientGroups!: RecipeIngredientGroupDto[];
tagList?: TagDto[];
}

View file

@ -5,6 +5,7 @@ import { CompactRecipeHandler } from "../../handlers/CompactRecipeHandler.js";
import { CompactRecipeDtoEntityMapper } from "../../mappers/CompactRecipeDtoEntityMapper.js";
import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js";
export const compactRecipeBasicRoute = "/compact-recipe"
/**
* Handles all recipe related routes
*/

View file

@ -8,7 +8,9 @@ import { RecipeIngredientDtoEntityMapper } from "../../mappers/RecipeIngredientD
import { RecipeIngredientGroupDtoEntityMapper } from "../../mappers/RecipeIngredientGroupDtoEntityMapper.js";
import { RecipeInstructionStepDtoEntityMapper } from "../../mappers/RecipeInstructionStepDtoEntityMapper.js";
import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js";
import {TagDtoEntityMapper} from "../../mappers/TagDtoEntityMapper.js";
export const recipeBasicRoute = "/recipe";
/**
* Handles all recipe related routes
*/
@ -19,7 +21,8 @@ const recipeRepository = new RecipeRepository();
const recipeIngredientMapper = new RecipeIngredientDtoEntityMapper();
const recipeIngredientGroupMapper = new RecipeIngredientGroupDtoEntityMapper(recipeIngredientMapper);
const recipeInstructionStepMapper = new RecipeInstructionStepDtoEntityMapper();
const recipeMapper = new RecipeDtoEntityMapper(recipeInstructionStepMapper, recipeIngredientGroupMapper);
const tagMapper = new TagDtoEntityMapper();
const recipeMapper = new RecipeDtoEntityMapper(recipeInstructionStepMapper, recipeIngredientGroupMapper, tagMapper);
const recipeHandler = new RecipeHandler(recipeRepository, recipeMapper);

View file

@ -16,15 +16,16 @@ import {requireAdmin} from "../../middleware/authorizationMiddleware.js";
* applied upstream (e.g. a global JWT guard). The adminOnly middleware below
* adds the role check that restricts DELETE to administrators.
*/
export const tagBasicRoute = "/tag";
const router = Router();
const tagHandler = new TagHandler();
/**
* GET /tags
* Returns all available tags.
*/
router.get("/", async (_req: Request, res: Response, next: NextFunction) => {
router.get("/all", async (_req: Request, res: Response, next: NextFunction) => {
try {
const tags = await tagHandler.getAll();
res.json(tags);
@ -36,6 +37,8 @@ router.get("/", async (_req: Request, res: Response, next: NextFunction) => {
/**
* POST /tags/create-or-update
* Creates a new tag or updates an existing one.
* If a tag with the given descriprion already exists in the database, that tag is returned instead of creating
* a duplicate.
* Body: TagDto
*/
router.post(

View file

@ -46,11 +46,12 @@ export class RecipeEntity extends AbstractEntity {
@ManyToMany(() => TagEntity, (tag) => tag.recipeList, {
cascade: ["insert", "update"],
eager: false,
nullable: true,
})
@JoinTable({
name: "recipe_tag",
joinColumn: { name: "recipe_id", referencedColumnName: "id" },
inverseJoinColumn: { name: "tag_id", referencedColumnName: "id" },
})
tagList!: TagEntity[];
tagList?: TagEntity[];
}

View file

@ -8,7 +8,7 @@ import { RecipeEntity } from "./RecipeEntity.js";
*/
@Entity({ name: "tag" })
export class TagEntity extends AbstractEntity {
@Column({ type: "varchar", length: 100, unique: true })
@Column({ type: "varchar", length: 100, unique: true, nullable: false })
description!: string;
/**

View file

@ -7,7 +7,7 @@ import { UserRole } from "../api/enums/UserRole.js";
*/
@Entity({ name: "user" })
export class UserEntity extends AbstractEntity {
@Column({ nullable: false, name: "user_name" })
@Column({ nullable: false, name: "user_name", unique: true })
userName!: string;
@Column({ nullable: false })

View file

@ -9,8 +9,9 @@ import { logger } from "./utils/logger.js";
import { HttpStatusCode } from "./api/apiHelpers/HttpStatusCodes.js";
import authRoutes, { authBasicRoute } from "./api/endpoints/AuthRestResource.js";
import userRoutes, { userBasicRoute } from "./api/endpoints/UserRestResource.js";
import compactRecipeRoutes from "./api/endpoints/CompactRecipeRestResource.js";
import recipeRoutes from "./api/endpoints/RecipeRestResource.js";
import compactRecipeRoutes, {compactRecipeBasicRoute} from "./api/endpoints/CompactRecipeRestResource.js";
import recipeRoutes, {recipeBasicRoute} from "./api/endpoints/RecipeRestResource.js";
import tagRoutes, {tagBasicRoute} from "./api/endpoints/TagRestResource.js";
dotenv.config();
@ -46,8 +47,9 @@ async function startServer() {
// Setup routes
app.use(authBasicRoute, authRoutes);
app.use(userBasicRoute, userRoutes);
app.use("/recipe", recipeRoutes);
app.use("/compact-recipe", compactRecipeRoutes);
app.use(recipeBasicRoute, recipeRoutes);
app.use(compactRecipeBasicRoute, compactRecipeRoutes);
app.use(tagBasicRoute,tagRoutes)
// Catch-all for unknown routes
app.use((req, res) => {

View file

@ -5,12 +5,14 @@ import { RecipeEntity } from "../entities/RecipeEntity.js";
import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js";
import { RecipeIngredientGroupDtoEntityMapper } from "./RecipeIngredientGroupDtoEntityMapper.js";
import { RecipeInstructionStepDtoEntityMapper } from "./RecipeInstructionStepDtoEntityMapper.js";
import { TagDtoEntityMapper } from "./TagDtoEntityMapper.js";
export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,RecipeDto>{
export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity, RecipeDto> {
constructor(
private instructionStepMapper : RecipeInstructionStepDtoEntityMapper,
private ingredientGroupMapper : RecipeIngredientGroupDtoEntityMapper
){
private instructionStepMapper: RecipeInstructionStepDtoEntityMapper,
private ingredientGroupMapper: RecipeIngredientGroupDtoEntityMapper,
private tagMapper: TagDtoEntityMapper
) {
super();
}
@ -19,24 +21,28 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
this.mapBaseEntityToDto(entity, dto);
dto.title = entity.title;
dto.amount = entity.amount
dto.amount = entity.amount;
dto.amountDescription = entity.amountDescription;
// map instructions
dto.instructions = entity.instructionSteps.map((stepEntity) => {
const instructionStep : RecipeInstructionStepDto = this.instructionStepMapper.toDto(stepEntity);
const instructionStep: RecipeInstructionStepDto = this.instructionStepMapper.toDto(stepEntity);
instructionStep.recipeId = entity.id;
return instructionStep;
});
// map ingredient groups
dto.ingredientGroups = entity.ingredientGroups.map((groupEntity) => {
const ingredientGroup :RecipeIngredientGroupDto = this.ingredientGroupMapper.toDto(groupEntity);
const ingredientGroup: RecipeIngredientGroupDto = this.ingredientGroupMapper.toDto(groupEntity);
ingredientGroup.recipeId = entity.id;
return ingredientGroup;
});
// map tags
dto.tagList = (entity.tagList ?? []).map((tagEntity) =>
this.tagMapper.toDto(tagEntity)
);
return dto;
}
@ -45,7 +51,7 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
this.mapBaseDtoToEntity(dto, entity);
entity.title = dto.title;
entity.amount = dto.amount
entity.amount = dto.amount;
entity.amountDescription = dto.amountDescription;
// map instructions
@ -53,7 +59,7 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
const stepEntity = this.instructionStepMapper.toEntity(stepDto);
// Set the relation if the entity already exists in DB
if(entity.hasValidId()){
if (entity.hasValidId()) {
stepEntity.recipe = entity;
}
@ -68,17 +74,25 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
// map ingredient groups
entity.ingredientGroups = dto.ingredientGroups.map((groupDto) => {
const groupEntity = this.ingredientGroupMapper.toEntity(groupDto);
// Set the relation if the entity already exists in DB
if(entity.hasValidId()){
if (entity.hasValidId()) {
groupEntity.recipe = entity;
}
// If it's a new group (no id from client), let DB generate a new UUID
if (!groupDto.id) {
delete (groupEntity as any).id;
}
return groupEntity;
});
// map tags
entity.tagList = (dto.tagList ?? []).map((tagDto) =>
this.tagMapper.toEntity(tagDto)
);
return entity;
}
@ -88,11 +102,26 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
entity.amountDescription = dto.amountDescription;
// --- Instruction Steps ---
entity.instructionSteps = this.instructionStepMapper.mergeDtoListIntoEntityList(dto.instructions, entity.instructionSteps);
entity.instructionSteps = this.instructionStepMapper.mergeDtoListIntoEntityList(
dto.instructions,
entity.instructionSteps
);
// --- Ingredient Groups ---
entity.ingredientGroups = this.ingredientGroupMapper.mergeDtoListIntoEntityList(dto.ingredientGroups, entity.ingredientGroups);
return entity
entity.ingredientGroups = this.ingredientGroupMapper.mergeDtoListIntoEntityList(
dto.ingredientGroups,
entity.ingredientGroups
);
// --- Tags ---
// Tags are looked up by ID and replaced wholesale: the recipe holds
// references to existing tag entities, so we map each DTO to its entity
// form and let TypeORM sync the join table on save.
entity.tagList = (dto.tagList ?? []).map((tagDto) =>
this.tagMapper.toEntity(tagDto)
);
return entity;
}
createNewEntity(): RecipeEntity {

View file

@ -1,15 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Migration to convert role column from varchar to enum
* Converts the `role` column on the `user` table from varchar to a proper
* PostgreSQL enum type.
*
* File name should be: TIMESTAMP-ConvertRoleToEnum.ts
* Example: 1701234567890-ConvertRoleToEnum.ts
* Existing rows are safe: the varchar values 'user' and 'admin' inserted by
* CreateUserTable1661234567890 match the enum members exactly, so the USING
* cast succeeds without any data transformation.
*
* Place in: src/migrations/
* down() reverts cleanly by casting back to text and dropping the enum type.
*/
export class ConvertRoleToEnum1701234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create the enum type if it doesn't already exist
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE user_role_enum AS ENUM ('user', 'admin');
@ -18,25 +21,56 @@ export class ConvertRoleToEnum1701234567890 implements MigrationInterface {
END $$;
`);
// Ensure any existing role values that differ only by whitespace or
// casing are normalised before the cast
await queryRunner.query(`
UPDATE "user"
SET "role" = LOWER(TRIM("role"))
WHERE "role" IS NOT NULL;
`);
// Drop the varchar default before retyping — PostgreSQL cannot cast it
// automatically and will reject the ALTER COLUMN otherwise
await queryRunner.query(`
ALTER TABLE "user"
ALTER COLUMN "role" DROP DEFAULT;
`);
// Convert the column — existing 'user' and 'admin' values cast directly
await queryRunner.query(`
ALTER TABLE "user"
ALTER COLUMN "role" TYPE user_role_enum
USING "role"::user_role_enum;
`);
// Re-apply the default using the enum literal
await queryRunner.query(`
ALTER TABLE "user"
ALTER COLUMN "role" SET DEFAULT 'user';
ALTER COLUMN "role" SET DEFAULT 'user'::user_role_enum;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop the enum default first so the column can be retyped
await queryRunner.query(`
ALTER TABLE "user"
ALTER COLUMN "role" DROP DEFAULT;
`);
// Cast enum values back to plain text
await queryRunner.query(`
ALTER TABLE "user"
ALTER COLUMN "role" TYPE varchar
USING "role"::text;
`);
// Restore the original varchar default
await queryRunner.query(`
ALTER TABLE "user"
ALTER COLUMN "role" SET DEFAULT 'user';
`);
// Drop the enum type — only safe once no column references it
await queryRunner.query(`
DROP TYPE IF EXISTS user_role_enum;
`);

View file

@ -1,4 +1,4 @@
import {MigrationInterface, QueryRunner, Table} from "typeorm";
import {MigrationInterface, QueryRunner, Table, TableUnique} from "typeorm";
/**
* Creates the `tag` table.
@ -15,6 +15,7 @@ export class CreateTagTable1771658108802 implements MigrationInterface {
type: "uuid",
isPrimary: true,
generationStrategy: "uuid",
isGenerated: true,
default: "uuid_generate_v4()",
},
{
@ -38,9 +39,20 @@ export class CreateTagTable1771658108802 implements MigrationInterface {
}),
true // ifNotExists
);
// Add a unique constraint on desciption
await queryRunner.createUniqueConstraint(
"tag",
new TableUnique({
columnNames: ["description"],
name: "UQ_tag_description",
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop the unique constraint first
await queryRunner.dropUniqueConstraint("tag", "UQ_tag_description");
await queryRunner.dropTable("tag", true);
}
}