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

View file

@ -5,6 +5,7 @@ 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";
export const compactRecipeBasicRoute = "/compact-recipe"
/** /**
* Handles all recipe related routes * Handles all recipe related routes
*/ */

View file

@ -8,7 +8,9 @@ import { RecipeIngredientDtoEntityMapper } from "../../mappers/RecipeIngredientD
import { RecipeIngredientGroupDtoEntityMapper } from "../../mappers/RecipeIngredientGroupDtoEntityMapper.js"; import { RecipeIngredientGroupDtoEntityMapper } from "../../mappers/RecipeIngredientGroupDtoEntityMapper.js";
import { RecipeInstructionStepDtoEntityMapper } from "../../mappers/RecipeInstructionStepDtoEntityMapper.js"; import { RecipeInstructionStepDtoEntityMapper } from "../../mappers/RecipeInstructionStepDtoEntityMapper.js";
import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js";
import {TagDtoEntityMapper} from "../../mappers/TagDtoEntityMapper.js";
export const recipeBasicRoute = "/recipe";
/** /**
* Handles all recipe related routes * Handles all recipe related routes
*/ */
@ -19,7 +21,8 @@ const recipeRepository = new RecipeRepository();
const recipeIngredientMapper = new RecipeIngredientDtoEntityMapper(); 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 tagMapper = new TagDtoEntityMapper();
const recipeMapper = new RecipeDtoEntityMapper(recipeInstructionStepMapper, recipeIngredientGroupMapper, tagMapper);
const recipeHandler = new RecipeHandler(recipeRepository, recipeMapper); 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 * applied upstream (e.g. a global JWT guard). The adminOnly middleware below
* adds the role check that restricts DELETE to administrators. * adds the role check that restricts DELETE to administrators.
*/ */
export const tagBasicRoute = "/tag";
const router = Router(); const router = Router();
const tagHandler = new TagHandler(); const tagHandler = new TagHandler();
/** /**
* GET /tags * GET /tags
* Returns all available 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 { try {
const tags = await tagHandler.getAll(); const tags = await tagHandler.getAll();
res.json(tags); res.json(tags);
@ -36,6 +37,8 @@ router.get("/", async (_req: Request, res: Response, next: NextFunction) => {
/** /**
* POST /tags/create-or-update * POST /tags/create-or-update
* Creates a new tag or updates an existing one. * 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 * Body: TagDto
*/ */
router.post( router.post(

View file

@ -46,11 +46,12 @@ export class RecipeEntity extends AbstractEntity {
@ManyToMany(() => TagEntity, (tag) => tag.recipeList, { @ManyToMany(() => TagEntity, (tag) => tag.recipeList, {
cascade: ["insert", "update"], cascade: ["insert", "update"],
eager: false, eager: false,
nullable: true,
}) })
@JoinTable({ @JoinTable({
name: "recipe_tag", name: "recipe_tag",
joinColumn: { name: "recipe_id", referencedColumnName: "id" }, joinColumn: { name: "recipe_id", referencedColumnName: "id" },
inverseJoinColumn: { name: "tag_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" }) @Entity({ name: "tag" })
export class TagEntity extends AbstractEntity { export class TagEntity extends AbstractEntity {
@Column({ type: "varchar", length: 100, unique: true }) @Column({ type: "varchar", length: 100, unique: true, nullable: false })
description!: string; description!: string;
/** /**

View file

@ -7,7 +7,7 @@ import { UserRole } from "../api/enums/UserRole.js";
*/ */
@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", unique: true })
userName!: string; userName!: string;
@Column({ nullable: false }) @Column({ nullable: false })

View file

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

View file

@ -5,12 +5,14 @@ import { RecipeEntity } from "../entities/RecipeEntity.js";
import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js"; import { AbstractDtoEntityMapper } from "./AbstractDtoEntityMapper.js";
import { RecipeIngredientGroupDtoEntityMapper } from "./RecipeIngredientGroupDtoEntityMapper.js"; import { RecipeIngredientGroupDtoEntityMapper } from "./RecipeIngredientGroupDtoEntityMapper.js";
import { RecipeInstructionStepDtoEntityMapper } from "./RecipeInstructionStepDtoEntityMapper.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( constructor(
private instructionStepMapper : RecipeInstructionStepDtoEntityMapper, private instructionStepMapper: RecipeInstructionStepDtoEntityMapper,
private ingredientGroupMapper : RecipeIngredientGroupDtoEntityMapper private ingredientGroupMapper: RecipeIngredientGroupDtoEntityMapper,
){ private tagMapper: TagDtoEntityMapper
) {
super(); super();
} }
@ -19,83 +21,110 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper<RecipeEntity,
this.mapBaseEntityToDto(entity, dto); this.mapBaseEntityToDto(entity, dto);
dto.title = entity.title; dto.title = entity.title;
dto.amount = entity.amount dto.amount = entity.amount;
dto.amountDescription = entity.amountDescription; dto.amountDescription = entity.amountDescription;
// map instructions // map instructions
dto.instructions = entity.instructionSteps.map((stepEntity) => { dto.instructions = entity.instructionSteps.map((stepEntity) => {
const instructionStep : RecipeInstructionStepDto = this.instructionStepMapper.toDto(stepEntity); const instructionStep: RecipeInstructionStepDto = this.instructionStepMapper.toDto(stepEntity);
instructionStep.recipeId = entity.id; instructionStep.recipeId = entity.id;
return instructionStep; return instructionStep;
}); });
// map ingredient groups // map ingredient groups
dto.ingredientGroups = entity.ingredientGroups.map((groupEntity) => { dto.ingredientGroups = entity.ingredientGroups.map((groupEntity) => {
const ingredientGroup :RecipeIngredientGroupDto = this.ingredientGroupMapper.toDto(groupEntity); const ingredientGroup: RecipeIngredientGroupDto = this.ingredientGroupMapper.toDto(groupEntity);
ingredientGroup.recipeId = entity.id; ingredientGroup.recipeId = entity.id;
return ingredientGroup; return ingredientGroup;
}); });
// map tags
dto.tagList = (entity.tagList ?? []).map((tagEntity) =>
this.tagMapper.toDto(tagEntity)
);
return dto; return dto;
} }
toEntity(dto: RecipeDto): RecipeEntity { toEntity(dto: RecipeDto): RecipeEntity {
const entity = new RecipeEntity(); const entity = new RecipeEntity();
this.mapBaseDtoToEntity(dto, entity); this.mapBaseDtoToEntity(dto, entity);
entity.title = dto.title; entity.title = dto.title;
entity.amount = dto.amount entity.amount = dto.amount;
entity.amountDescription = dto.amountDescription; entity.amountDescription = dto.amountDescription;
// map instructions // map instructions
entity.instructionSteps = dto.instructions.map((stepDto) => { entity.instructionSteps = dto.instructions.map((stepDto) => {
const stepEntity = this.instructionStepMapper.toEntity(stepDto); const stepEntity = this.instructionStepMapper.toEntity(stepDto);
// Set the relation if the entity already exists in DB // Set the relation if the entity already exists in DB
if(entity.hasValidId()){ if (entity.hasValidId()) {
stepEntity.recipe = entity; 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
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()) {
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;
} }
// If it's a new step (no id from client), let DB generate a new UUID mergeDtoIntoEntity(dto: RecipeDto, entity: RecipeEntity): RecipeEntity {
if (!stepDto.id) { entity.title = dto.title;
delete (stepEntity as any).id; 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
);
// --- 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;
} }
return stepEntity; createNewEntity(): RecipeEntity {
}); return new 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()){
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;
});
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

@ -1,44 +1,78 @@
import { MigrationInterface, QueryRunner } from "typeorm"; 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 * Existing rows are safe: the varchar values 'user' and 'admin' inserted by
* Example: 1701234567890-ConvertRoleToEnum.ts * 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 { export class ConvertRoleToEnum1701234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
// Create the enum type if it doesn't already exist
await queryRunner.query(` await queryRunner.query(`
DO $$ BEGIN DO $$ BEGIN
CREATE TYPE user_role_enum AS ENUM ('user', 'admin'); CREATE TYPE user_role_enum AS ENUM ('user', 'admin');
EXCEPTION EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
`); `);
// Ensure any existing role values that differ only by whitespace or
// casing are normalised before the cast
await queryRunner.query(` await queryRunner.query(`
ALTER TABLE "user" UPDATE "user"
ALTER COLUMN "role" TYPE user_role_enum SET "role" = LOWER(TRIM("role"))
USING "role"::user_role_enum; 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(` await queryRunner.query(`
ALTER TABLE "user" ALTER TABLE "user"
ALTER COLUMN "role" SET DEFAULT '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'::user_role_enum;
`);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
// Drop the enum default first so the column can be retyped
await queryRunner.query(` await queryRunner.query(`
ALTER TABLE "user" ALTER TABLE "user"
ALTER COLUMN "role" TYPE varchar ALTER COLUMN "role" DROP DEFAULT;
USING "role"::text; `);
`);
// Cast enum values back to plain text
await queryRunner.query(` await queryRunner.query(`
DROP TYPE IF EXISTS user_role_enum; 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. * Creates the `tag` table.
@ -15,6 +15,7 @@ export class CreateTagTable1771658108802 implements MigrationInterface {
type: "uuid", type: "uuid",
isPrimary: true, isPrimary: true,
generationStrategy: "uuid", generationStrategy: "uuid",
isGenerated: true,
default: "uuid_generate_v4()", default: "uuid_generate_v4()",
}, },
{ {
@ -38,9 +39,20 @@ export class CreateTagTable1771658108802 implements MigrationInterface {
}), }),
true // ifNotExists 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> { public async down(queryRunner: QueryRunner): Promise<void> {
// Drop the unique constraint first
await queryRunner.dropUniqueConstraint("tag", "UQ_tag_description");
await queryRunner.dropTable("tag", true); await queryRunner.dropTable("tag", true);
} }
} }