diff --git a/src/api/dtos/RecipeDto.ts b/src/api/dtos/RecipeDto.ts index b8d757f..c0c2cc9 100644 --- a/src/api/dtos/RecipeDto.ts +++ b/src/api/dtos/RecipeDto.ts @@ -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[]; } \ No newline at end of file diff --git a/src/api/endpoints/CompactRecipeRestResource.ts b/src/api/endpoints/CompactRecipeRestResource.ts index 0dfa735..1f282e9 100644 --- a/src/api/endpoints/CompactRecipeRestResource.ts +++ b/src/api/endpoints/CompactRecipeRestResource.ts @@ -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 */ diff --git a/src/api/endpoints/RecipeRestResource.ts b/src/api/endpoints/RecipeRestResource.ts index f441feb..2081259 100644 --- a/src/api/endpoints/RecipeRestResource.ts +++ b/src/api/endpoints/RecipeRestResource.ts @@ -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); diff --git a/src/api/endpoints/TagRestResource.ts b/src/api/endpoints/TagRestResource.ts index 4dfa4e9..ddc622a 100644 --- a/src/api/endpoints/TagRestResource.ts +++ b/src/api/endpoints/TagRestResource.ts @@ -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( diff --git a/src/entities/RecipeEntity.ts b/src/entities/RecipeEntity.ts index c946707..773a428 100644 --- a/src/entities/RecipeEntity.ts +++ b/src/entities/RecipeEntity.ts @@ -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[]; } \ No newline at end of file diff --git a/src/entities/TagEntity.ts b/src/entities/TagEntity.ts index e50532b..7ac5b85 100644 --- a/src/entities/TagEntity.ts +++ b/src/entities/TagEntity.ts @@ -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; /** diff --git a/src/entities/UserEntity.ts b/src/entities/UserEntity.ts index 621e926..b31124a 100644 --- a/src/entities/UserEntity.ts +++ b/src/entities/UserEntity.ts @@ -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 }) diff --git a/src/index.ts b/src/index.ts index de20338..0e94ea6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) => { diff --git a/src/mappers/RecipeDtoEntityMapper.ts b/src/mappers/RecipeDtoEntityMapper.ts index f5bb591..fe4240a 100644 --- a/src/mappers/RecipeDtoEntityMapper.ts +++ b/src/mappers/RecipeDtoEntityMapper.ts @@ -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{ +export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper { constructor( - private instructionStepMapper : RecipeInstructionStepDtoEntityMapper, - private ingredientGroupMapper : RecipeIngredientGroupDtoEntityMapper - ){ + private instructionStepMapper: RecipeInstructionStepDtoEntityMapper, + private ingredientGroupMapper: RecipeIngredientGroupDtoEntityMapper, + private tagMapper: TagDtoEntityMapper + ) { super(); } @@ -19,83 +21,110 @@ export class RecipeDtoEntityMapper extends AbstractDtoEntityMapper { - const instructionStep : RecipeInstructionStepDto = this.instructionStepMapper.toDto(stepEntity); - instructionStep.recipeId = entity.id; - return instructionStep; + 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); - ingredientGroup.recipeId = entity.id; - return ingredientGroup; + 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; } - toEntity(dto: RecipeDto): RecipeEntity { - const entity = new RecipeEntity(); - this.mapBaseDtoToEntity(dto, entity); + toEntity(dto: RecipeDto): RecipeEntity { + const entity = new RecipeEntity(); + this.mapBaseDtoToEntity(dto, entity); - entity.title = dto.title; - entity.amount = dto.amount - entity.amountDescription = dto.amountDescription; + entity.title = dto.title; + entity.amount = dto.amount; + entity.amountDescription = dto.amountDescription; - // map instructions - entity.instructionSteps = dto.instructions.map((stepDto) => { - const stepEntity = this.instructionStepMapper.toEntity(stepDto); + // map instructions + entity.instructionSteps = dto.instructions.map((stepDto) => { + const stepEntity = this.instructionStepMapper.toEntity(stepDto); - // Set the relation if the entity already exists in DB - if(entity.hasValidId()){ - stepEntity.recipe = entity; + // Set the relation if the entity already exists in DB + if (entity.hasValidId()) { + 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 - if (!stepDto.id) { - delete (stepEntity as any).id; + 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 + ); + + // --- 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; - }); - - // 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; + createNewEntity(): RecipeEntity { + return new RecipeEntity(); } - // 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(); - } } \ No newline at end of file diff --git a/src/migrations/1701234567890-ConvertRoleToEnum.ts b/src/migrations/1701234567890-ConvertRoleToEnum.ts index af59a19..3f99d30 100644 --- a/src/migrations/1701234567890-ConvertRoleToEnum.ts +++ b/src/migrations/1701234567890-ConvertRoleToEnum.ts @@ -1,44 +1,78 @@ 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 { + // Create the enum type if it doesn't already exist await queryRunner.query(` - DO $$ BEGIN - CREATE TYPE user_role_enum AS ENUM ('user', 'admin'); - EXCEPTION - WHEN duplicate_object THEN null; - END $$; - `); + DO $$ BEGIN + CREATE TYPE user_role_enum AS ENUM ('user', 'admin'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `); + // Ensure any existing role values that differ only by whitespace or + // casing are normalised before the cast await queryRunner.query(` - ALTER TABLE "user" - ALTER COLUMN "role" TYPE user_role_enum - USING "role"::user_role_enum; - `); + 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" SET DEFAULT 'user'; - `); + 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'::user_role_enum; + `); } public async down(queryRunner: QueryRunner): Promise { + // Drop the enum default first so the column can be retyped await queryRunner.query(` - ALTER TABLE "user" - ALTER COLUMN "role" TYPE varchar - USING "role"::text; - `); + ALTER TABLE "user" + ALTER COLUMN "role" DROP DEFAULT; + `); + // Cast enum values back to plain text 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; + `); } } \ No newline at end of file diff --git a/src/migrations/1771658108802-CreateTagTable.ts b/src/migrations/1771658108802-CreateTagTable.ts index 9e4ad59..fb1b2d6 100644 --- a/src/migrations/1771658108802-CreateTagTable.ts +++ b/src/migrations/1771658108802-CreateTagTable.ts @@ -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 { + // Drop the unique constraint first + await queryRunner.dropUniqueConstraint("tag", "UQ_tag_description"); await queryRunner.dropTable("tag", true); } }