diff --git a/packages/api/.eslintrc b/packages/api/.eslintrc index c91bcdeda..54aae2bf1 100644 --- a/packages/api/.eslintrc +++ b/packages/api/.eslintrc @@ -52,6 +52,12 @@ } ] } + }, + { + "files": ["src/**/*.entity.{ts,js}"], + "rules": { + "import/no-cycle": "off" + } } ] } diff --git a/packages/api/migration/1599586648784-ReefAdminManyToMany.ts b/packages/api/migration/1599586648784-ReefAdminManyToMany.ts new file mode 100644 index 000000000..f60bcaca8 --- /dev/null +++ b/packages/api/migration/1599586648784-ReefAdminManyToMany.ts @@ -0,0 +1,76 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ReefAdminManyToMany1599586648784 implements MigrationInterface { + name = 'ReefAdminManyToMany1599586648784'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "reef" DROP CONSTRAINT "FK_dc56bfd6bfcd1f221ec83885294"`, + ); + await queryRunner.query( + ` + CREATE TABLE "users_administered_reefs_reef" ( + "reef_id" integer NOT NULL, + "users_id" integer NOT NULL, + CONSTRAINT "PK_21f162e26e837a19d1e1accd1cd" PRIMARY KEY ("reef_id", "users_id") + ) + `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_088a629ef23eb9eba6ac857ed6" ON "users_administered_reefs_reef" ("reef_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_da52b9542bf7df43f4840ae439" ON "users_administered_reefs_reef" ("users_id") `, + ); + await queryRunner.query(`ALTER TABLE "reef" DROP COLUMN "admin_id"`); + await queryRunner.query( + ` + ALTER TABLE "users_administered_reefs_reef" ADD CONSTRAINT "FK_088a629ef23eb9eba6ac857ed62" + FOREIGN KEY ("reef_id") REFERENCES "reef"("id") ON DELETE CASCADE ON UPDATE NO ACTION + `, + ); + await queryRunner.query( + ` + ALTER TABLE "users_administered_reefs_reef" ADD CONSTRAINT "FK_da52b9542bf7df43f4840ae4394" + FOREIGN KEY ("users_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION + `, + ); + await queryRunner.query( + `ALTER TABLE "reef_application" DROP CONSTRAINT "FK_77d33d9b9602120cd1529312e77"`, + ); + await queryRunner.query( + `ALTER TABLE "reef_application" DROP CONSTRAINT "UQ_77d33d9b9602120cd1529312e77"`, + ); + await queryRunner.query( + `ALTER TABLE "reef_application" ADD CONSTRAINT "FK_77d33d9b9602120cd1529312e77" FOREIGN KEY ("reef_id") REFERENCES "reef"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users_administered_reefs_reef" DROP CONSTRAINT "FK_da52b9542bf7df43f4840ae4394"`, + ); + await queryRunner.query( + `ALTER TABLE "users_administered_reefs_reef" DROP CONSTRAINT "FK_088a629ef23eb9eba6ac857ed62"`, + ); + await queryRunner.query(`ALTER TABLE "reef" ADD "admin_id" integer`); + await queryRunner.query(`DROP INDEX "IDX_da52b9542bf7df43f4840ae439"`); + await queryRunner.query(`DROP INDEX "IDX_088a629ef23eb9eba6ac857ed6"`); + await queryRunner.query(`DROP TABLE "users_administered_reefs_reef"`); + await queryRunner.query( + ` + ALTER TABLE "reef" ADD CONSTRAINT "FK_dc56bfd6bfcd1f221ec83885294" + FOREIGN KEY ("admin_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION + `, + ); + await queryRunner.query( + `ALTER TABLE "reef_application" DROP CONSTRAINT "FK_77d33d9b9602120cd1529312e77"`, + ); + await queryRunner.query( + `ALTER TABLE "reef_application" ADD CONSTRAINT "UQ_77d33d9b9602120cd1529312e77" UNIQUE ("reef_id")`, + ); + await queryRunner.query( + `ALTER TABLE "reef_application" ADD CONSTRAINT "FK_77d33d9b9602120cd1529312e77" FOREIGN KEY ("reef_id") REFERENCES "reef"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +} diff --git a/packages/api/src/auth/auth.decorator.ts b/packages/api/src/auth/auth.decorator.ts index 38beee6ad..d52ebd2c1 100644 --- a/packages/api/src/auth/auth.decorator.ts +++ b/packages/api/src/auth/auth.decorator.ts @@ -4,9 +4,6 @@ import { FirebaseAuthGuard } from './firebase-auth.guard'; import { LevelsGuard } from './levels.guard'; export const Auth = (...levels: AdminLevel[]) => { - if (!levels) { - return UseGuards(FirebaseAuthGuard); - } return applyDecorators( SetMetadata('levels', levels), UseGuards(FirebaseAuthGuard, LevelsGuard), diff --git a/packages/api/src/auth/firebase-auth.guard.ts b/packages/api/src/auth/firebase-auth.guard.ts index 1ae136a05..e921814e8 100644 --- a/packages/api/src/auth/firebase-auth.guard.ts +++ b/packages/api/src/auth/firebase-auth.guard.ts @@ -1,5 +1,24 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, ExecutionContext } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { of } from 'rxjs'; @Injectable() -export class FirebaseAuthGuard extends AuthGuard('custom') {} +export class FirebaseAuthGuard extends AuthGuard('custom') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.get( + 'isPublic', + context.getHandler(), + ); + + if (isPublic) { + return of(true); + } + + return super.canActivate(context); + } +} diff --git a/packages/api/src/auth/is-reef-admin.guard.ts b/packages/api/src/auth/is-reef-admin.guard.ts new file mode 100644 index 000000000..15b27dc13 --- /dev/null +++ b/packages/api/src/auth/is-reef-admin.guard.ts @@ -0,0 +1,48 @@ +import { CanActivate, ExecutionContext } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Reflector } from '@nestjs/core'; +import { Repository } from 'typeorm'; +import { User, AdminLevel } from '../users/users.entity'; +import { Reef } from '../reefs/reefs.entity'; + +export class IsReefAdminGuard implements CanActivate { + constructor( + @InjectRepository(Reef) + private reefRepository: Repository, + + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.get( + 'isPublic', + context.getHandler(), + ); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const { user }: { user: User } = request; + const reefId = parseInt(request.params.reef_id, 10); + + if (user.adminLevel === AdminLevel.SuperAdmin) { + return true; + } + + if (!Number.isNaN(reefId) && user.adminLevel === AdminLevel.ReefManager) { + const isReefAdmin = await this.reefRepository + .createQueryBuilder('reef') + .innerJoin('reef.admins', 'admins', 'admins.id = :userId', { + userId: user.id, + }) + .andWhere('reef.id = :reefId', { reefId }) + .getOne(); + + return !!isReefAdmin; + } + + return false; + } +} diff --git a/packages/api/src/auth/levels.guard.ts b/packages/api/src/auth/levels.guard.ts index a34f47581..1b4c00b24 100644 --- a/packages/api/src/auth/levels.guard.ts +++ b/packages/api/src/auth/levels.guard.ts @@ -1,12 +1,20 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AdminLevel } from '../users/users.entity'; +import { AuthRequest } from './auth.types'; @Injectable() export class LevelsGuard implements CanActivate { constructor(private reflector: Reflector) {} async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.get( + 'isPublic', + context.getHandler(), + ); + if (isPublic) { + return true; + } const levels = this.reflector.get( 'levels', context.getHandler(), @@ -14,7 +22,7 @@ export class LevelsGuard implements CanActivate { if (!levels || !levels.length) { return true; } - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); const { user } = request; const hasAccess = levels.findIndex((l) => l === user.adminLevel) !== -1; return hasAccess; diff --git a/packages/api/src/auth/override-level-access.decorator.ts b/packages/api/src/auth/override-level-access.decorator.ts new file mode 100644 index 000000000..8dc9be319 --- /dev/null +++ b/packages/api/src/auth/override-level-access.decorator.ts @@ -0,0 +1,6 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { AdminLevel } from '../users/users.entity'; + +export const OverrideLevelAccess = (...levels: AdminLevel[]) => { + return applyDecorators(SetMetadata('levels', levels)); +}; diff --git a/packages/api/src/auth/public.decorator.ts b/packages/api/src/auth/public.decorator.ts new file mode 100644 index 000000000..2415f00c4 --- /dev/null +++ b/packages/api/src/auth/public.decorator.ts @@ -0,0 +1,5 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; + +export const Public = () => { + return applyDecorators(SetMetadata('isPublic', true)); +}; diff --git a/packages/api/src/reef-applications/reef-applications.entity.ts b/packages/api/src/reef-applications/reef-applications.entity.ts index 9c1efd4d5..1e4b417f5 100644 --- a/packages/api/src/reef-applications/reef-applications.entity.ts +++ b/packages/api/src/reef-applications/reef-applications.entity.ts @@ -2,11 +2,9 @@ import { Entity, PrimaryGeneratedColumn, Column, - OneToOne, ManyToOne, CreateDateColumn, UpdateDateColumn, - JoinColumn, } from 'typeorm'; import { Exclude, Expose } from 'class-transformer'; import { Reef } from '../reefs/reefs.entity'; @@ -41,8 +39,7 @@ export class ReefApplication { @UpdateDateColumn() updatedAt: Date; - @OneToOne(() => Reef, { onDelete: 'CASCADE' }) - @JoinColumn() + @ManyToOne(() => Reef, { onDelete: 'CASCADE' }) reef: Reef; @ManyToOne(() => User, { onDelete: 'CASCADE' }) diff --git a/packages/api/src/reef-pois/reef-pois.controller.ts b/packages/api/src/reef-pois/reef-pois.controller.ts index b93c02c90..adb62828d 100644 --- a/packages/api/src/reef-pois/reef-pois.controller.ts +++ b/packages/api/src/reef-pois/reef-pois.controller.ts @@ -16,12 +16,13 @@ import { FilterReefPoiDto } from './dto/filter-reef-poi.dto'; import { UpdateReefPoiDto } from './dto/update-reef-poi.dto'; import { AdminLevel } from '../users/users.entity'; import { Auth } from '../auth/auth.decorator'; +import { Public } from '../auth/public.decorator'; +@Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Controller('pois') export class ReefPoisController { constructor(private poisService: ReefPoisService) {} - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Post() create( @Body() createReefPoiDto: CreateReefPoiDto, @@ -29,6 +30,7 @@ export class ReefPoisController { return this.poisService.create(createReefPoiDto); } + @Public() @Get() find( @Query() filterReefPoiDto: FilterReefPoiDto, @@ -36,12 +38,12 @@ export class ReefPoisController { return this.poisService.find(filterReefPoiDto); } + @Public() @Get(':id') findOne(@Param('id', ParseIntPipe) id: number): Promise { return this.poisService.findOne(id); } - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Put(':id') update( @Param('id', ParseIntPipe) id: number, @@ -50,7 +52,6 @@ export class ReefPoisController { return this.poisService.update(id, updateReefPoiDto); } - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Delete(':id') delete(@Param('id', ParseIntPipe) id: number): Promise { return this.poisService.delete(id); diff --git a/packages/api/src/reef-pois/reef-pois.entity.ts b/packages/api/src/reef-pois/reef-pois.entity.ts index 41552486d..93703ad9e 100644 --- a/packages/api/src/reef-pois/reef-pois.entity.ts +++ b/packages/api/src/reef-pois/reef-pois.entity.ts @@ -7,9 +7,7 @@ import { UpdateDateColumn, OneToMany, } from 'typeorm'; -// eslint-disable-next-line import/no-cycle import { Reef } from '../reefs/reefs.entity'; -// eslint-disable-next-line import/no-cycle import { SurveyMedia } from '../surveys/survey-media.entity'; @Entity() diff --git a/packages/api/src/reefs/daily-data.entity.ts b/packages/api/src/reefs/daily-data.entity.ts index 07a9ebc95..2b61db95b 100644 --- a/packages/api/src/reefs/daily-data.entity.ts +++ b/packages/api/src/reefs/daily-data.entity.ts @@ -6,7 +6,6 @@ import { CreateDateColumn, UpdateDateColumn, } from 'typeorm'; -// eslint-disable-next-line import/no-cycle import { Reef } from './reefs.entity'; @Entity() diff --git a/packages/api/src/reefs/reefs.controller.ts b/packages/api/src/reefs/reefs.controller.ts index 2f1fd40b4..3416e516c 100644 --- a/packages/api/src/reefs/reefs.controller.ts +++ b/packages/api/src/reefs/reefs.controller.ts @@ -14,48 +14,39 @@ import { Reef } from './reefs.entity'; import { CreateReefDto } from './dto/create-reef.dto'; import { FilterReefDto } from './dto/filter-reef.dto'; import { UpdateReefDto } from './dto/update-reef.dto'; -import surveys from '../../mock_response/survey_data.json'; import { AdminLevel } from '../users/users.entity'; import { Auth } from '../auth/auth.decorator'; +import { Public } from '../auth/public.decorator'; +@Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Controller('reefs') export class ReefsController { constructor(private reefsService: ReefsService) {} - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Post() create(@Body() createReefDto: CreateReefDto): Promise { return this.reefsService.create(createReefDto); } + @Public() @Get() find(@Query() filterReefDto: FilterReefDto): Promise { return this.reefsService.find(filterReefDto); } + @Public() @Get(':id') findOne(@Param('id', ParseIntPipe) id: number): Promise { return this.reefsService.findOne(id); } + @Public() @Get(':id/daily_data') // eslint-disable-next-line no-unused-vars findDailyData(@Param('id') id: number) { return this.reefsService.findDailyData(id); } - @Get(':id/surveys/:poi') - findSurveys(@Param('id') id: string, @Param('poi') poi: string) { - return surveys.map((survey) => ({ - ...survey, - images: survey.images.filter( - (image) => image.poi_label_id === parseInt(poi, 10), - ), - })); - // return this.reefRepository.findOneReef(id); - } - - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Put(':id') update( @Param('id', ParseIntPipe) id: number, @@ -64,7 +55,6 @@ export class ReefsController { return this.reefsService.update(id, updateReefDto); } - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Delete(':id') delete(@Param('id', ParseIntPipe) id: number): Promise { return this.reefsService.delete(id); diff --git a/packages/api/src/reefs/reefs.entity.ts b/packages/api/src/reefs/reefs.entity.ts index 5a3fad4c5..b4494ab99 100644 --- a/packages/api/src/reefs/reefs.entity.ts +++ b/packages/api/src/reefs/reefs.entity.ts @@ -9,14 +9,13 @@ import { UpdateDateColumn, OneToOne, OneToMany, + ManyToMany, } from 'typeorm'; import { Region } from '../regions/regions.entity'; -import { User } from '../users/users.entity'; -// eslint-disable-next-line import/no-cycle import { DailyData } from './daily-data.entity'; import { VideoStream } from './video-streams.entity'; -// eslint-disable-next-line import/no-cycle import { Survey } from '../surveys/surveys.entity'; +import { User } from '../users/users.entity'; @Entity() export class Reef { @@ -64,15 +63,15 @@ export class Reef { @ManyToOne(() => Region, { onDelete: 'SET NULL', nullable: true }) region?: Region; - @ManyToOne(() => User, { nullable: true }) - admin?: User; - @ManyToOne(() => VideoStream, { onDelete: 'SET NULL', nullable: true }) stream?: VideoStream; + @ManyToMany(() => User, (user) => user.administeredReefs) + admins: User[]; + @OneToOne(() => DailyData, (latestDailyData) => latestDailyData.reef) latestDailyData?: DailyData; @OneToMany(() => Survey, (survey) => survey.reef) - surveys?: Survey[]; + surveys: Survey[]; } diff --git a/packages/api/src/reefs/reefs.service.ts b/packages/api/src/reefs/reefs.service.ts index 42e8b1568..0799ed8b1 100644 --- a/packages/api/src/reefs/reefs.service.ts +++ b/packages/api/src/reefs/reefs.service.ts @@ -49,12 +49,15 @@ export class ReefsService { }); } if (filter.admin) { - query.andWhere('reef.admin = :admin', { - admin: filter.admin, - }); + query.innerJoin( + 'reef.admins', + 'adminsAssociation', + 'adminsAssociation.id = :adminId', + { adminId: filter.admin }, + ); } query.leftJoinAndSelect('reef.region', 'region'); - query.leftJoinAndSelect('reef.admin', 'admin'); + query.leftJoinAndSelect('reef.admins', 'admins'); query.leftJoinAndSelect('reef.stream', 'stream'); query.leftJoinAndSelect( 'reef.latestDailyData', @@ -66,7 +69,7 @@ export class ReefsService { async findOne(id: number): Promise { const found = await this.reefsRepository.findOne(id, { - relations: ['region', 'admin', 'stream'], + relations: ['region', 'admins', 'stream'], }); if (!found) { throw new NotFoundException(`Reef with ID ${id} not found.`); diff --git a/packages/api/src/regions/regions.controller.ts b/packages/api/src/regions/regions.controller.ts index f956f6df7..0ad0887bb 100644 --- a/packages/api/src/regions/regions.controller.ts +++ b/packages/api/src/regions/regions.controller.ts @@ -16,28 +16,30 @@ import { FilterRegionDto } from './dto/filter-region.dto'; import { UpdateRegionDto } from './dto/update-region.dto'; import { Auth } from '../auth/auth.decorator'; import { AdminLevel } from '../users/users.entity'; +import { Public } from '../auth/public.decorator'; +@Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Controller('regions') export class RegionsController { constructor(private regionsService: RegionsService) {} - @Auth(AdminLevel.SuperAdmin) @Post() create(@Body() createRegionDto: CreateRegionDto): Promise { return this.regionsService.create(createRegionDto); } + @Public() @Get() find(@Query() filterRegionDto: FilterRegionDto): Promise { return this.regionsService.find(filterRegionDto); } + @Public() @Get(':id') findOne(@Param('id', ParseIntPipe) id: number): Promise { return this.regionsService.findOne(id); } - @Auth(AdminLevel.SuperAdmin) @Put(':id') update( @Param('id', ParseIntPipe) id: number, @@ -46,7 +48,6 @@ export class RegionsController { return this.regionsService.update(id, updateRegionDto); } - @Auth(AdminLevel.SuperAdmin) @Delete(':id') delete(@Param('id', ParseIntPipe) id: number): Promise { return this.regionsService.delete(id); diff --git a/packages/api/src/surveys/dto/create-survey.dto.ts b/packages/api/src/surveys/dto/create-survey.dto.ts index c6416f43b..12059bdb3 100644 --- a/packages/api/src/surveys/dto/create-survey.dto.ts +++ b/packages/api/src/surveys/dto/create-survey.dto.ts @@ -1,14 +1,5 @@ -import { - IsDateString, - IsEnum, - IsString, - IsOptional, - IsInt, - Validate, -} from 'class-validator'; +import { IsDateString, IsEnum, IsString, IsOptional } from 'class-validator'; import { WeatherConditions } from '../surveys.entity'; -import { EntityExists } from '../../validations/entity-exists.constraint'; -import { Reef } from '../../reefs/reefs.entity'; export class CreateSurveyDto { @IsDateString() @@ -20,8 +11,4 @@ export class CreateSurveyDto { @IsString() @IsOptional() readonly comments?: string; - - @IsInt() - @Validate(EntityExists, [Reef]) - readonly reef: Reef; } diff --git a/packages/api/src/surveys/survey-media.entity.ts b/packages/api/src/surveys/survey-media.entity.ts index 44400277f..5087b92ac 100644 --- a/packages/api/src/surveys/survey-media.entity.ts +++ b/packages/api/src/surveys/survey-media.entity.ts @@ -7,9 +7,7 @@ import { CreateDateColumn, UpdateDateColumn, } from 'typeorm'; -// eslint-disable-next-line import/no-cycle import { Survey } from './surveys.entity'; -// eslint-disable-next-line import/no-cycle import { ReefPointOfInterest } from '../reef-pois/reef-pois.entity'; export enum Observations { diff --git a/packages/api/src/surveys/surveys.controller.ts b/packages/api/src/surveys/surveys.controller.ts index 96d97e1f4..72a7d12ab 100644 --- a/packages/api/src/surveys/surveys.controller.ts +++ b/packages/api/src/surveys/surveys.controller.ts @@ -9,6 +9,7 @@ import { Get, Put, Delete, + UseGuards, } from '@nestjs/common'; import { AcceptFile } from '../uploads/file.decorator'; import { Auth } from '../auth/auth.decorator'; @@ -20,12 +21,16 @@ import { CreateSurveyMediaDto } from './dto/create-survey-media.dto'; import { SurveyMedia } from './survey-media.entity'; import { EditSurveyDto } from './dto/edit-survey.dto'; import { EditSurveyMediaDto } from './dto/edit-survey-media.dto'; +import { IsReefAdminGuard } from '../auth/is-reef-admin.guard'; +import { AuthRequest } from '../auth/auth.types'; +import { Public } from '../auth/public.decorator'; -@Controller('surveys') +@UseGuards(IsReefAdminGuard) +@Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) +@Controller('reefs/:reef_id/surveys') export class SurveysController { constructor(private surveyService: SurveysService) {} - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Post('upload') @AcceptFile('file', ['image', 'video'], 'surveys', 'reef') upload(@UploadedFile('file') file: any): string { @@ -35,16 +40,15 @@ export class SurveysController { return `https://storage.googleapis.com/${process.env.GCS_BUCKET}/${file.filename}`; } - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Post() create( @Body() createSurveyDto: CreateSurveyDto, - @Req() req: any, + @Param('reef_id', ParseIntPipe) reefId: number, + @Req() req: AuthRequest, ): Promise { - return this.surveyService.create(createSurveyDto, req.user); + return this.surveyService.create(createSurveyDto, req.user, reefId); } - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Post(':id/media') createMedia( @Body() createSurveyMediaDto: CreateSurveyMediaDto, @@ -53,22 +57,24 @@ export class SurveysController { return this.surveyService.createMedia(createSurveyMediaDto, surveyId); } - @Get('reefs/:id') - find(@Param('id', ParseIntPipe) reefId: number): Promise { + @Public() + @Get() + find(@Param('reef_id', ParseIntPipe) reefId: number): Promise { return this.surveyService.find(reefId); } + @Public() @Get(':id') findOne(@Param('id', ParseIntPipe) surveyId: number): Promise { return this.surveyService.findOne(surveyId); } + @Public() @Get(':id/media') findMedia(@Param('id', ParseIntPipe) surveyId): Promise { return this.surveyService.findMedia(surveyId); } - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Put('media/:id') updateMedia( @Param('id', ParseIntPipe) mediaId: number, @@ -77,7 +83,6 @@ export class SurveysController { return this.surveyService.updateMedia(editSurveyMediaDto, mediaId); } - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Put(':id') update( @Param('id', ParseIntPipe) surveyId: number, @@ -86,13 +91,11 @@ export class SurveysController { return this.surveyService.update(editSurveyDto, surveyId); } - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Delete(':id') delete(@Param('id', ParseIntPipe) surveyId: number): Promise { return this.surveyService.delete(surveyId); } - @Auth(AdminLevel.ReefManager, AdminLevel.SuperAdmin) @Delete('media/:id') deleteMedia(@Param('id', ParseIntPipe) mediaId: number): Promise { return this.surveyService.deleteMedia(mediaId); diff --git a/packages/api/src/surveys/surveys.entity.ts b/packages/api/src/surveys/surveys.entity.ts index d39b8465b..0863720ec 100644 --- a/packages/api/src/surveys/surveys.entity.ts +++ b/packages/api/src/surveys/surveys.entity.ts @@ -8,14 +8,10 @@ import { UpdateDateColumn, OneToOne, } from 'typeorm'; -// eslint-disable-next-line import/no-cycle import { Reef } from '../reefs/reefs.entity'; import { User } from '../users/users.entity'; -// eslint-disable-next-line import/no-cycle import { DailyData } from '../reefs/daily-data.entity'; -// eslint-disable-next-line import/no-cycle import { ReefPointOfInterest } from '../reef-pois/reef-pois.entity'; -// eslint-disable-next-line import/no-cycle import { SurveyMedia } from './survey-media.entity'; export enum WeatherConditions { diff --git a/packages/api/src/surveys/surveys.module.ts b/packages/api/src/surveys/surveys.module.ts index 4abc09bca..b595c528d 100644 --- a/packages/api/src/surveys/surveys.module.ts +++ b/packages/api/src/surveys/surveys.module.ts @@ -9,12 +9,13 @@ import { SurveyMedia } from './survey-media.entity'; import { ReefPointOfInterest } from '../reef-pois/reef-pois.entity'; import { GoogleCloudModule } from '../google-cloud/google-cloud.module'; import { GoogleCloudService } from '../google-cloud/google-cloud.service'; +import { Reef } from '../reefs/reefs.entity'; @Module({ imports: [ AuthModule, GoogleCloudModule, - TypeOrmModule.forFeature([Survey, SurveyMedia, ReefPointOfInterest]), + TypeOrmModule.forFeature([Survey, SurveyMedia, ReefPointOfInterest, Reef]), ], controllers: [SurveysController], providers: [EntityExists, SurveysService, GoogleCloudService], diff --git a/packages/api/src/surveys/surveys.service.ts b/packages/api/src/surveys/surveys.service.ts index c4a524d4c..d767ba2d4 100644 --- a/packages/api/src/surveys/surveys.service.ts +++ b/packages/api/src/surveys/surveys.service.ts @@ -14,6 +14,7 @@ import { ReefPointOfInterest } from '../reef-pois/reef-pois.entity'; import { EditSurveyDto } from './dto/edit-survey.dto'; import { EditSurveyMediaDto } from './dto/edit-survey-media.dto'; import { GoogleCloudService } from '../google-cloud/google-cloud.service'; +import { Reef } from '../reefs/reefs.entity'; @Injectable() export class SurveysService { @@ -27,13 +28,27 @@ export class SurveysService { @InjectRepository(ReefPointOfInterest) private poiRepository: Repository, + @InjectRepository(Reef) + private reefRepository: Repository, + private googleCloudService: GoogleCloudService, ) {} // Create a survey - async create(createSurveyDto: CreateSurveyDto, user: User): Promise { + async create( + createSurveyDto: CreateSurveyDto, + user: User, + reefId: number, + ): Promise { + const reef = await this.reefRepository.findOne(reefId); + + if (!reef) { + throw new NotFoundException(`Reef with id ${reefId} was not found.`); + } + const survey = await this.surveyRepository.save({ userId: user, + reef, ...createSurveyDto, comments: this.transformComments(createSurveyDto.comments), }); diff --git a/packages/api/src/users/users.controller.ts b/packages/api/src/users/users.controller.ts index 35ebeea4d..bb68688da 100644 --- a/packages/api/src/users/users.controller.ts +++ b/packages/api/src/users/users.controller.ts @@ -14,23 +14,27 @@ import { AdminLevel, User } from './users.entity'; import { CreateUserDto } from './dto/create-user.dto'; import { Auth } from '../auth/auth.decorator'; import { AuthRequest } from '../auth/auth.types'; +import { Reef } from '../reefs/reefs.entity'; +import { OverrideLevelAccess } from '../auth/override-level-access.decorator'; +import { Public } from '../auth/public.decorator'; +@Auth() @Controller('users') export class UsersController { constructor(private usersService: UsersService) {} + @Public() @Post() create(@Req() req: any, @Body() createUserDto: CreateUserDto): Promise { return this.usersService.create(req, createUserDto); } - @Auth() @Get('current') getSelf(@Req() req: AuthRequest): Promise { return this.usersService.getSelf(req); } - @Auth(AdminLevel.SuperAdmin) + @OverrideLevelAccess(AdminLevel.SuperAdmin) @Put(':id/level') setAdminLevel( @Param('id', ParseIntPipe) id: number, @@ -39,9 +43,14 @@ export class UsersController { return this.usersService.setAdminLevel(id, adminLevel); } - @Auth(AdminLevel.SuperAdmin) + @OverrideLevelAccess(AdminLevel.SuperAdmin) @Delete(':id') delete(@Param('id', ParseIntPipe) id: number): Promise { return this.usersService.delete(id); } + + @Get('current/administered-reefs') + getAdministeredReefs(@Req() req: AuthRequest): Promise { + return this.usersService.getAdministeredReefs(req); + } } diff --git a/packages/api/src/users/users.entity.ts b/packages/api/src/users/users.entity.ts index a756a45f2..0512a41b4 100644 --- a/packages/api/src/users/users.entity.ts +++ b/packages/api/src/users/users.entity.ts @@ -6,8 +6,11 @@ import { Index, CreateDateColumn, UpdateDateColumn, + ManyToMany, + JoinTable, } from 'typeorm'; import { Exclude } from 'class-transformer'; +import { Reef } from '../reefs/reefs.entity'; export enum AdminLevel { Default = 'default', @@ -59,6 +62,10 @@ export class User { @Column({ nullable: true }) imageUrl?: string; + @ManyToMany(() => Reef, (reef) => reef.admins, { cascade: true }) + @JoinTable() + administeredReefs: Reef[]; + @CreateDateColumn() createdAt: Date; diff --git a/packages/api/src/users/users.module.ts b/packages/api/src/users/users.module.ts index 4ed8583dc..63c289af5 100644 --- a/packages/api/src/users/users.module.ts +++ b/packages/api/src/users/users.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { User } from './users.entity'; +import { ReefApplication } from '../reef-applications/reef-applications.entity'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User, ReefApplication])], controllers: [UsersController], providers: [UsersService], exports: [UsersService], diff --git a/packages/api/src/users/users.service.ts b/packages/api/src/users/users.service.ts index bfd079e93..9d54e6fa8 100644 --- a/packages/api/src/users/users.service.ts +++ b/packages/api/src/users/users.service.ts @@ -9,12 +9,17 @@ import { AuthRequest } from '../auth/auth.types'; import { extractAndVerifyToken } from '../auth/firebase-auth.strategy'; import { CreateUserDto } from './dto/create-user.dto'; import { AdminLevel, User } from './users.entity'; +import { ReefApplication } from '../reef-applications/reef-applications.entity'; +import { Reef } from '../reefs/reefs.entity'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private usersRepository: Repository, + + @InjectRepository(ReefApplication) + private reefApplicationRepository: Repository, ) {} async create(req: any, createUserDto: CreateUserDto): Promise { @@ -39,12 +44,22 @@ export class UsersService { `Email ${email} is already connected to a different firebaseUid.`, ); } - const data = { + + if (priorAccount) { + const newUser = await this.migrateUserAssociations(priorAccount); + // User has associations so we have to explicitly change their admin level to reef manager + if ( + newUser.administeredReefs.length && + priorAccount.adminLevel !== AdminLevel.SuperAdmin + ) { + // eslint-disable-next-line fp/no-mutation + priorAccount.adminLevel = AdminLevel.ReefManager; + } + } + + const user = { ...priorAccount, ...createUserDto, - }; - const user = { - ...data, firebaseUid, }; return this.usersRepository.save(user); @@ -54,6 +69,19 @@ export class UsersService { return req.user; } + async getAdministeredReefs(req: AuthRequest): Promise { + const user = await this.usersRepository.findOne({ + where: { id: req.user.id }, + relations: ['administeredReefs'], + }); + + if (!user) { + throw new NotFoundException(`User with ID ${req.user.id} not found.`); + } + + return user.administeredReefs; + } + async findByEmail(email: string): Promise { return this.usersRepository.findOne({ where: { email } }); } @@ -75,4 +103,38 @@ export class UsersService { throw new NotFoundException(`User with ID ${id} not found.`); } } + + /** + * Transfer the associations between the user and the reefs from the reef-application table + */ + private async migrateUserAssociations(user: User) { + const reefAssociations = await this.reefApplicationRepository.find({ + where: { user }, + relations: ['reef'], + }); + + const newAdministeredReefs: Reef[] = []; + + reefAssociations.forEach((reefAssociation) => { + const { reef } = reefAssociation; + + const relationshipExists = newAdministeredReefs.find((newReef) => { + return newReef.id === reef.id; + }); + + // If relationship already exists, skip + if (relationshipExists) { + return; + } + + // eslint-disable-next-line fp/no-mutating-methods + newAdministeredReefs.push(reef); + }); + + const newUser = { + id: user.id, + administeredReefs: newAdministeredReefs, + }; + return this.usersRepository.save(newUser); + } }