Add routes and fix mappers and migration scripts
This commit is contained in:
parent
58a581fbac
commit
801739ea30
11 changed files with 184 additions and 97 deletions
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
10
src/index.ts
10
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) => {
|
||||
|
|
|
|||
|
|
@ -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,83 +21,110 @@ 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);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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');
|
||||
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<void> {
|
||||
// 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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue