diff --git a/sr-back/migrations/1686955681436-CreateNeighbiurProfileTable.ts b/sr-back/migrations/1686955681436-CreateNeighbiurProfileTable.ts new file mode 100644 index 0000000..928d518 --- /dev/null +++ b/sr-back/migrations/1686955681436-CreateNeighbiurProfileTable.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateNeighbiurProfileTable1686955681436 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.startTransaction(); + queryRunner.query(` + CREATE TYPE "gender_type" AS ENUM('man', 'woman'); + `); + queryRunner.query(` + CREATE TYPE "tenant_status" AS ENUM('solo', 'family', 'friends'); + `); + queryRunner.query(` + CREATE TYPE "rental_period" AS ENUM('few_weeks', 'few_months', 'year_and_more'); + `); + + queryRunner.query(` + CREATE TABLE IF NOT EXISTS "neighbour_profile" ( + "id" SERIAL PRIMARY KEY, + "userId" SERIAL REFERENCES "user" ("id"), + "age" SMALLSERIAL NOT NULL, + "gender" "gender_type" NOT NULL, + "tenantStatus" "tenant_status" NOT NULL, + "pets" boolean, + "petsDescription" VARCHAR(255), + "countryId" SERIAL REFERENCES "area" ("id"), + "cityId" SERIAL REFERENCES "area" ("id"), + "rentalPerdiod" "rental_period" NOT NULL, + "budget" SMALLSERIAL, + "description" VARCHAR(300), + "photoLink" VARCHAR(255) NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now() + ); + `); + + await queryRunner.commitTransaction(); + } + + public async down(queryRunner: QueryRunner): Promise { + queryRunner.query(`DROP TABLE IF EXISTS "neighbour_profile"`); + queryRunner.query(`DROP TYPE IF EXISTS "gender_type"`); + queryRunner.query(`DROP TYPE IF EXISTS "tenant_status"`); + queryRunner.query(`DROP TYPE IF EXISTS "rental_period"`); + } +} diff --git a/sr-back/src/app/app.controller.spec.ts b/sr-back/src/app/app.controller.spec.ts index 10d4d98..41accc5 100644 --- a/sr-back/src/app/app.controller.spec.ts +++ b/sr-back/src/app/app.controller.spec.ts @@ -6,7 +6,6 @@ import { App } from './entities/app.entity'; describe('AppController', () => { let appController: AppController; - let appService: AppService; const mockApp = { id: 0, lastRequestedAt: new Date() }; beforeEach(async () => { diff --git a/sr-back/src/app/modules/core.module.ts b/sr-back/src/app/modules/core.module.ts index 5a667d9..83fb6bd 100644 --- a/sr-back/src/app/modules/core.module.ts +++ b/sr-back/src/app/modules/core.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { UserModule } from './user/user.module'; +import { NeighbourProfileModule } from './neighbour-profile/neighbour-profile.module'; -@Module({ imports: [UserModule] }) +@Module({ imports: [UserModule, NeighbourProfileModule] }) export class CoreModule {} diff --git a/sr-back/src/app/modules/neighbour-profile/dto/create-neighbour-profile.dto.ts b/sr-back/src/app/modules/neighbour-profile/dto/create-neighbour-profile.dto.ts new file mode 100644 index 0000000..a41b680 --- /dev/null +++ b/sr-back/src/app/modules/neighbour-profile/dto/create-neighbour-profile.dto.ts @@ -0,0 +1,61 @@ +import { Expose } from 'class-transformer'; +import { + IsBoolean, + IsEnum, + IsInt, + IsNotEmpty, + IsString, +} from 'class-validator'; +import { + Gender, + RentalPeriod, + TenantStatus, +} from '../entities/neighbour-profile.entity'; + +export class CreateNeighbourProfileInput { + @Expose() + @IsInt() + age: number; + + @Expose() + @IsEnum(Gender) + gender: Gender; + + @Expose() + @IsEnum(TenantStatus) + tenantStatus: TenantStatus; + + @Expose() + @IsNotEmpty() + @IsBoolean() + pets: boolean; + + @Expose() + petsDescription: string; + + @Expose() + @IsNotEmpty() + @IsInt() + countryId: number; + + @Expose() + @IsNotEmpty() + @IsInt() + cityId: number; + + @Expose() + @IsEnum(RentalPeriod) + rentalPeriod: RentalPeriod; + + @Expose() + @IsNotEmpty() + @IsInt() + budget: number; + + @Expose() + @IsNotEmpty() + @IsString() + description: string; + + // ToDo: add photo uploading +} diff --git a/sr-back/src/app/modules/neighbour-profile/dto/neighbour-profile.dto.ts b/sr-back/src/app/modules/neighbour-profile/dto/neighbour-profile.dto.ts new file mode 100644 index 0000000..f1c3315 --- /dev/null +++ b/sr-back/src/app/modules/neighbour-profile/dto/neighbour-profile.dto.ts @@ -0,0 +1,74 @@ +import { Expose } from 'class-transformer'; +import { + Gender, + TenantStatus, + RentalPeriod, + NeighbourProfile, +} from '../entities/neighbour-profile.entity'; +import { AreaDTO } from 'src/area/dto/area.dto'; + +export class NeighbourProfileDTO { + @Expose() + age: number; + + @Expose() + gender: Gender; + + @Expose() + tenantStatus: TenantStatus; + + @Expose() + pets: boolean; + + @Expose() + petsDescription: string; + + @Expose() + country: AreaDTO; + + @Expose() + city: AreaDTO; + + @Expose() + rentalPeriod: RentalPeriod; + + @Expose() + budget: number; + + @Expose() + description: string; + + constructor(profile: NeighbourProfile) { + this.age = profile.age; + this.gender = profile.gender; + this.tenantStatus = profile.tenantStatus; + this.pets = profile.pets; + this.petsDescription = profile.petsDescription; + this.rentalPeriod = profile.rentalPeriod; + this.description = profile.description; + this.budget = profile.budget; + + if (profile.country !== null && profile.country !== undefined) { + this.country = new AreaDTO(profile.country); + } + + if (profile.city !== null && profile.city !== undefined) { + this.city = new AreaDTO(profile.city); + } + } +} + +export class NeighbourProfilesDTO extends NeighbourProfile { + static fromEntity(profiles: NeighbourProfile[]) { + return profiles.map((profile) => new NeighbourProfileDTO(profile)); + } +} + +export class SingleNeighbourProfileDTO { + @Expose() + profile: NeighbourProfileDTO; + + static fromEntity(profile: NeighbourProfile) { + return { profile: new NeighbourProfileDTO(profile) }; + } +} diff --git a/sr-back/src/app/modules/neighbour-profile/dto/update-neighbour-profile.dto.ts b/sr-back/src/app/modules/neighbour-profile/dto/update-neighbour-profile.dto.ts new file mode 100644 index 0000000..993c802 --- /dev/null +++ b/sr-back/src/app/modules/neighbour-profile/dto/update-neighbour-profile.dto.ts @@ -0,0 +1,10 @@ +import { OmitType, PartialType } from '@nestjs/swagger'; +import { CreateNeighbourProfileInput } from './create-neighbour-profile.dto'; + +export class UpdateNeighbourProfileInput extends PartialType( + OmitType(CreateNeighbourProfileInput, [ + 'gender', + 'tenantStatus', + 'rentalPeriod', + ] as const), +) {} diff --git a/sr-back/src/app/modules/neighbour-profile/entities/neighbour-profile.entity.ts b/sr-back/src/app/modules/neighbour-profile/entities/neighbour-profile.entity.ts new file mode 100644 index 0000000..bf75e12 --- /dev/null +++ b/sr-back/src/app/modules/neighbour-profile/entities/neighbour-profile.entity.ts @@ -0,0 +1,84 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; +import { Area } from 'src/area/entities/area.entity'; +import { ValidateIf } from 'class-validator'; + +export enum Gender { + man = 'man', + woman = 'woman', +} + +export enum TenantStatus { + solo = 'solo', + family = 'family', + friends = 'friends', +} + +export enum RentalPeriod { + few_weeks = 'few_weeks', + few_months = 'few_months', + year_and_more = 'year_and_more', +} + +@Entity() +export class NeighbourProfile { + @PrimaryGeneratedColumn() + id: number; + + @OneToOne(() => User) + @JoinColumn() + user: User; + + @Column() + age: number; + + @Column({ enum: Gender, type: 'enum' }) + gender: Gender; + + @Column({ enum: TenantStatus, type: 'enum' }) + tenantStatus: TenantStatus; + + @Column() + pets: boolean; + + @Column() + petsDescription: string; + + @Column({ nullable: true }) + @OneToOne(() => Area) + @JoinColumn() + @ValidateIf((u) => (u.country === undefined || u.country === null) && u.city) + country: Area; + + @Column({ nullable: true }) + @OneToOne(() => Area) + @ValidateIf((u) => (u.city === undefined || u.city === null) && u.country) + @JoinColumn() + city: Area; + + @Column({ enum: RentalPeriod, type: 'enum' }) + rentalPeriod: RentalPeriod; + + @Column() + budget: number; + + @Column({ length: 300 }) + description: string; + + @Column() + photoLink: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/sr-back/src/app/modules/neighbour-profile/neighbour-profile.controller.spec.ts b/sr-back/src/app/modules/neighbour-profile/neighbour-profile.controller.spec.ts new file mode 100644 index 0000000..72ad92c --- /dev/null +++ b/sr-back/src/app/modules/neighbour-profile/neighbour-profile.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NeighbourProfileController } from './neighbour-profile.controller'; + +describe('NeighbourProfileController', () => { + let controller: NeighbourProfileController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [NeighbourProfileController], + }).compile(); + + controller = module.get( + NeighbourProfileController, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/sr-back/src/app/modules/neighbour-profile/neighbour-profile.controller.ts b/sr-back/src/app/modules/neighbour-profile/neighbour-profile.controller.ts new file mode 100644 index 0000000..4777884 --- /dev/null +++ b/sr-back/src/app/modules/neighbour-profile/neighbour-profile.controller.ts @@ -0,0 +1,97 @@ +import { + Body, + Controller, + Delete, + Get, + Inject, + Param, + Post, + Put, + Query, + Req, + UnauthorizedException, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { + NeighbourProfileAlreadyExists, + NeighbourProfileNotFound, + NeighbourProfileService, +} from './neighbour-profile.service'; +import { User } from '../user/entities/user.entity'; +import { Request } from 'express'; +import { CreateNeighbourProfileInput } from './dto/create-neighbour-profile.dto'; +import { UpdateNeighbourProfileInput } from './dto/update-neighbour-profile.dto'; +import { MapErrorToHTTP } from 'src/common/decorators/MapErrorToHTTP'; +import { AreaNotFoundError } from 'src/area/area.service'; +import { + SerializeTo, + SerializeWithPagingTo, +} from 'src/common/decorators/SerializeTo'; +import { + NeighbourProfilesDTO, + SingleNeighbourProfileDTO, +} from './dto/neighbour-profile.dto'; +import { PageOptionsDTO } from 'src/common/dto/pagination.dto'; + +@Controller('neighbour-profile') +@ApiTags('NeghbourProfile') +export class NeighbourProfileController { + @Inject() + private readonly neighbourProfileService: NeighbourProfileService; + + @Get(':id') + @SerializeTo(SingleNeighbourProfileDTO) + @MapErrorToHTTP(NeighbourProfileNotFound, UnauthorizedException) + async getNeighbourProfile(@Param('id') userId: number) { + return await this.neighbourProfileService.getNeighbourProfileByUserId( + userId, + ); + } + + @Get('all') + @SerializeWithPagingTo(NeighbourProfilesDTO) + async getAllNeighbourProfiles(@Query() pageOptions: PageOptionsDTO) { + return await this.neighbourProfileService.getNeighbourProfiles(pageOptions); + } + + @Post('/:id') + @MapErrorToHTTP(AreaNotFoundError, UnauthorizedException) + @MapErrorToHTTP(NeighbourProfileAlreadyExists, UnauthorizedException) + async createNeighbourProfile( + @Body() createNeighbourProfile: CreateNeighbourProfileInput, + // @Req() { user }: Request, + @Param('id') id: number, + ) { + const res = await this.neighbourProfileService.createNeighbourProfile( + createNeighbourProfile, + id, + ); + + return res; + } + + @Put('/:id') + @MapErrorToHTTP(NeighbourProfileNotFound, UnauthorizedException) + async updateNeighbourProfile( + @Body() updateNeighbourProfile: UpdateNeighbourProfileInput, + // @Req() { user }: Request, + @Param('id') id: number, + ) { + const res = await this.neighbourProfileService.updateNeighbourProfile( + updateNeighbourProfile, + id, + ); + + return res; + } + + @Delete('/:id') + async deleteNeighbourProfile( + // @Req() { user }: Request, + @Param('id') id: number, + ) { + const res = await this.neighbourProfileService.deleteNeighbourProfile(id); + + return res; + } +} diff --git a/sr-back/src/app/modules/neighbour-profile/neighbour-profile.module.ts b/sr-back/src/app/modules/neighbour-profile/neighbour-profile.module.ts new file mode 100644 index 0000000..470d4e4 --- /dev/null +++ b/sr-back/src/app/modules/neighbour-profile/neighbour-profile.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { NeighbourProfileController } from './neighbour-profile.controller'; +import { NeighbourProfileService } from './neighbour-profile.service'; +import { AreaModule } from 'src/area/area.module'; +import { UserModule } from '../user/user.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '../user/entities/user.entity'; + +@Module({ + imports: [AreaModule, UserModule, TypeOrmModule.forFeature([User])], + controllers: [NeighbourProfileController], + providers: [NeighbourProfileService], +}) +export class NeighbourProfileModule {} diff --git a/sr-back/src/app/modules/neighbour-profile/neighbour-profile.service.spec.ts b/sr-back/src/app/modules/neighbour-profile/neighbour-profile.service.spec.ts new file mode 100644 index 0000000..d81dd34 --- /dev/null +++ b/sr-back/src/app/modules/neighbour-profile/neighbour-profile.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NeighbourProfileService } from './neighbour-profile.service'; + +describe('NeighbourProfileService', () => { + let service: NeighbourProfileService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [NeighbourProfileService], + }).compile(); + + service = module.get(NeighbourProfileService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/sr-back/src/app/modules/neighbour-profile/neighbour-profile.service.ts b/sr-back/src/app/modules/neighbour-profile/neighbour-profile.service.ts new file mode 100644 index 0000000..c31e207 --- /dev/null +++ b/sr-back/src/app/modules/neighbour-profile/neighbour-profile.service.ts @@ -0,0 +1,159 @@ +import { Injectable } from '@nestjs/common'; +import { CreateNeighbourProfileInput } from './dto/create-neighbour-profile.dto'; +import { UpdateNeighbourProfileInput } from './dto/update-neighbour-profile.dto'; +import { User } from '../user/entities/user.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, EntityNotFoundError } from 'typeorm'; +import { NeighbourProfile } from './entities/neighbour-profile.entity'; +import { AreaService } from 'src/area/area.service'; +import { + PageMetaDTO, + PageOptionsDTO, + ResponseWithPagination, +} from 'src/common/dto/pagination.dto'; +import { UserService } from '../user/user.service'; +import { Area } from 'src/area/entities/area.entity'; + +export class NeighbourProfileAlreadyExists extends Error {} +export class NeighbourProfileNotFound extends Error {} + +@Injectable() +export class NeighbourProfileService { + constructor( + @InjectRepository(User) + private readonly neighbourProfileRepository: Repository, + private readonly areaService: AreaService, + private readonly userService: UserService, + ) {} + + async createNeighbourProfile( + body: CreateNeighbourProfileInput, + // user: User, + id: number, + ): Promise { + const { countryId, cityId }: CreateNeighbourProfileInput = body; + + const user = await this.userService.getUserById(id); + + const profileExists = await this.neighbourProfileRepository.exist({ + where: { + user, + }, + }); + + if (profileExists) { + throw new NeighbourProfileAlreadyExists(); + } + + const country = await this.areaService.findOne(countryId); + const city = await this.areaService.findOne(cityId); + + const neighbourProfile = await this.neighbourProfileRepository.insert({ + ...body, + user, + country, + city, + }); + + return neighbourProfile.raw[0] as NeighbourProfile; + } + + async updateNeighbourProfile( + body: UpdateNeighbourProfileInput, + // user: User, + id: number, + ) { + try { + let { countryId, cityId }: UpdateNeighbourProfileInput = body; + + const user = await this.userService.getUserById(id); + + const existingProfile = + await this.neighbourProfileRepository.findOneOrFail({ + where: { + user, + }, + }); + + let isCityOrCountryChecked = false; + + if (cityId !== null && cityId !== undefined && !isCityOrCountryChecked) { + const city = await this.areaService.findOne(cityId); + if (city.parent !== null && Boolean(city.parent.id)) { + await this.areaService.areaExists(city.parent.id); + } + isCityOrCountryChecked = true; + } + + if ( + countryId !== null && + countryId !== undefined && + !isCityOrCountryChecked + ) { + await this.areaService.areaExists(countryId); + cityId = undefined; + } + + return ( + await this.neighbourProfileRepository.update( + { id: existingProfile.id }, + { ...body }, + ) + ).raw[0] as NeighbourProfile; + } catch (error) { + if (error instanceof EntityNotFoundError) { + throw new NeighbourProfileNotFound(); + } + + throw error; + } + } + + async deleteNeighbourProfile(id: number): Promise { + const user = await this.userService.getUserById(id); + return (await this.neighbourProfileRepository.delete({ user })).affected; + } + + async getNeighbourProfileByUserId( + id: number, + ): Promise { + try { + return await this.neighbourProfileRepository.findOneOrFail({ + where: { id }, + relations: { user: true }, + }); + } catch (error) { + if (error instanceof EntityNotFoundError) { + throw new NeighbourProfileNotFound(); + } + + throw error; + } + } + + async getNeighbourProfiles( + pageOptions: PageOptionsDTO, + // searchOptions: NeighbourProfilesSearchOptions, + ): Promise> { + try { + const searchOptions = {}; + + const itemsTotal = await this.neighbourProfileRepository.count({ + where: searchOptions, + }); + const data = await this.neighbourProfileRepository.find({ + where: searchOptions, + take: pageOptions.take, + skip: pageOptions.skip, + }); + + return { data, meta: new PageMetaDTO(itemsTotal, pageOptions) }; + } catch (error) { + if (error instanceof EntityNotFoundError) { + throw new NeighbourProfileNotFound(); + } + + throw error; + } + } +} diff --git a/sr-back/src/app/modules/user/entities/user.entity.ts b/sr-back/src/app/modules/user/entities/user.entity.ts index f9070d7..80053c6 100644 --- a/sr-back/src/app/modules/user/entities/user.entity.ts +++ b/sr-back/src/app/modules/user/entities/user.entity.ts @@ -1,4 +1,10 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; export enum UserRole { user = 'user', @@ -25,9 +31,9 @@ export class User { @Column({ enum: UserRole, type: 'enum', default: UserRole.user }) role: UserRole; - @Column({ default: 'now()', select: false }) + @CreateDateColumn() createdAt: Date; - @Column({ default: 'now()', select: false }) + @UpdateDateColumn() updatedAt: Date; } diff --git a/sr-back/src/app/modules/user/user.module.ts b/sr-back/src/app/modules/user/user.module.ts index 5974a76..9ff9a7b 100644 --- a/sr-back/src/app/modules/user/user.module.ts +++ b/sr-back/src/app/modules/user/user.module.ts @@ -8,5 +8,6 @@ import { User } from './entities/user.entity'; imports: [TypeOrmModule.forFeature([User])], providers: [UserService], controllers: [UserController], + exports: [UserService], }) export class UserModule {} diff --git a/sr-back/src/area/area.module.ts b/sr-back/src/area/area.module.ts index 98a32fa..49f52ed 100644 --- a/sr-back/src/area/area.module.ts +++ b/sr-back/src/area/area.module.ts @@ -8,5 +8,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; controllers: [AreaController], providers: [AreaService], imports: [TypeOrmModule.forFeature([Area])], + exports: [AreaService], }) export class AreaModule {} diff --git a/sr-back/src/area/area.service.ts b/sr-back/src/area/area.service.ts index 93ba606..930fd5d 100644 --- a/sr-back/src/area/area.service.ts +++ b/sr-back/src/area/area.service.ts @@ -31,6 +31,21 @@ export class AreaService { } } + async areaExists(id: number) { + try { + return await this.areaRepository.exist({ + where: { id }, + relations: { parent: true, child: true }, + }); + } catch (error) { + if (error instanceof EntityNotFoundError) { + throw new AreaNotFoundError(); + } + + throw error; + } + } + async findAllCountries({ withCities = false, pageOptions, diff --git a/sr-back/src/area/entities/area.entity.ts b/sr-back/src/area/entities/area.entity.ts index 0fa7215..8e37ce7 100644 --- a/sr-back/src/area/entities/area.entity.ts +++ b/sr-back/src/area/entities/area.entity.ts @@ -1,9 +1,11 @@ import { Column, + CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; export enum AreaType { @@ -28,9 +30,9 @@ export class Area { @OneToMany(() => Area, (area) => area.parent) child: Area[]; - @Column({ default: 'now()', select: false }) + @CreateDateColumn() createdAt: Date; - @Column({ default: 'now()', select: false }) + @UpdateDateColumn() updatedAt: Date; }