Skip to content

Commit

Permalink
chore(api): projects: make place for acl/resource logic before crud
Browse files Browse the repository at this point in the history
  • Loading branch information
kgajowy committed May 28, 2021
1 parent b0d1976 commit 98080d2
Show file tree
Hide file tree
Showing 7 changed files with 313 additions and 238 deletions.
16 changes: 16 additions & 0 deletions api/apps/api/src/modules/projects/dto/geo-feature.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { GeoFeaturesService } from '../../geo-features/geo-features.service';
import { PaginationMeta } from '../../../utils/app-base.service';
import { GeoFeature } from '../../geo-features/geo-feature.api.entity';

@Injectable()
export class GeoFeatureMapper {
constructor(private readonly geoFeaturesService: GeoFeaturesService) {}

async serialize(
entities: Partial<GeoFeature> | (Partial<GeoFeature> | undefined)[],
paginationMeta?: PaginationMeta,
): Promise<any> {
return this.geoFeaturesService.serialize(entities, paginationMeta);
}
}
16 changes: 16 additions & 0 deletions api/apps/api/src/modules/projects/dto/project-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { PaginationMeta } from '../../../utils/app-base.service';
import { ProjectsCrud } from '../projects-crud';
import { Project } from '../project.api.entity';

@Injectable()
export class ProjectMapper {
constructor(private readonly projectsCrud: ProjectsCrud) {}

async serialize(
entities: Partial<Project> | (Partial<Project> | undefined)[],
paginationMeta?: PaginationMeta,
): Promise<any> {
return this.projectsCrud.serialize(entities, paginationMeta);
}
}
202 changes: 202 additions & 0 deletions api/apps/api/src/modules/projects/projects-crud.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AppInfoDTO } from 'dto/info.dto';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Project } from './project.api.entity';
import { CreateProjectDTO } from './dto/create.project.dto';
import { UpdateProjectDTO } from './dto/update.project.dto';
import { UsersService } from 'modules/users/users.service';
import { ScenariosService } from 'modules/scenarios/scenarios.service';
import { PlanningUnitsService } from 'modules/planning-units/planning-units.service';
import {
AppBaseService,
JSONAPISerializerConfig,
} from 'utils/app-base.service';
import { Country } from 'modules/countries/country.geo.entity';
import { AdminArea } from 'modules/admin-areas/admin-area.geo.entity';
import { AdminAreasService } from 'modules/admin-areas/admin-areas.service';
import { CountriesService } from 'modules/countries/countries.service';
import { AppConfig } from 'utils/config.utils';

const projectFilterKeyNames = [
'name',
'organizationId',
'countryId',
'adminAreaLevel1Id',
'adminAreaLevel2Id',
] as const;
type ProjectFilterKeys = keyof Pick<
Project,
typeof projectFilterKeyNames[number]
>;
type ProjectFilters = Record<ProjectFilterKeys, string[]>;

@Injectable()
export class ProjectsCrud extends AppBaseService<
Project,
CreateProjectDTO,
UpdateProjectDTO,
AppInfoDTO
> {
constructor(
@InjectRepository(Project)
protected readonly repository: Repository<Project>,
@Inject(forwardRef(() => ScenariosService))
protected readonly scenariosService: ScenariosService,
@Inject(UsersService) protected readonly usersService: UsersService,
@Inject(AdminAreasService)
protected readonly adminAreasService: AdminAreasService,
@Inject(CountriesService)
protected readonly countriesService: CountriesService,
@Inject(PlanningUnitsService)
private readonly planningUnitsService: PlanningUnitsService,
) {
super(repository, 'project', 'projects', {
logging: { muteAll: AppConfig.get<boolean>('logging.muteAll', false) },
});
}

get serializerConfig(): JSONAPISerializerConfig<Project> {
return {
attributes: [
'name',
'description',
'countryId',
'adminAreaLevel1Id',
'adminAreaLevel2Id',
'planningUnitGridShape',
'planningUnitAreakm2',
'users',
'scenarios',
'createdAt',
'lastModifiedAt',
],
keyForAttribute: 'camelCase',
users: {
ref: 'id',
attributes: ['fname', 'lname', 'email', 'projectRoles'],
projectRoles: {
ref: 'name',
attributes: ['name'],
},
},
scenarios: {
ref: 'id',
attributes: [
'name',
'description',
'type',
'wdpaFilter',
'wdpaThreshold',
'adminRegionId',
'numberOfRuns',
'boundaryLengthModifier',
'metadata',
'status',
'createdAt',
'lastModifiedAt',
],
},
};
}

/**
* Apply service-specific filters.
*/
setFilters(
query: SelectQueryBuilder<Project>,
filters: ProjectFilters,
_info?: AppInfoDTO,
): SelectQueryBuilder<Project> {
this._processBaseFilters<ProjectFilters>(
query,
filters,
projectFilterKeyNames,
);
return query;
}

async setDataCreate(
create: CreateProjectDTO,
info?: AppInfoDTO,
): Promise<Project> {
/**
* @debt Temporary setup. I think we should remove TimeUserEntityMetadata
* from entities and just use a separate event log, and a view to obtain the
* same information (who created an entity and when, and when it was last
* modified) from that log, kind of event sourcing way.
*/
const project = await super.setDataCreate(create, info);
project.createdBy = info?.authenticatedUser?.id!;
return project;
}

/**
* Look up the planning area for this project.
*
* In decreasing precedence (i.e. most specific is used):
*
* * a project-specific protected area (@todo not implemented yet)
* * a level 2 admin area
* * a level 1 admin area
* * a country
*/
async getPlanningArea(
project: Partial<Project>,
): Promise<Country | Partial<AdminArea | undefined>> {
const planningArea = project.planningAreaGeometryId
? /**
* @todo here we should look up the actual custom planning area from
* `planningAreaGeometryId`, when we implement this.
*/
new AdminArea()
: project.adminAreaLevel2Id
? await this.adminAreasService.getByLevel1OrLevel2Id(
project.adminAreaLevel2Id!,
)
: project.adminAreaLevel1Id
? await this.adminAreasService.getByLevel1OrLevel2Id(
project.adminAreaLevel1Id!,
)
: project.countryId
? await this.countriesService.getById(project.countryId)
: undefined;
return planningArea;
}

async actionAfterCreate(
model: Project,
createModel: CreateProjectDTO,
_info?: AppInfoDTO,
): Promise<void> {
if (
createModel.planningUnitAreakm2 &&
createModel.planningUnitGridShape &&
(createModel.countryId ||
createModel.adminAreaLevel1Id ||
createModel.adminAreaLevel2Id ||
createModel.extent)
) {
this.logger.debug('creating planning unit job ');
return this.planningUnitsService.create(createModel);
}
}

async actionAfterUpdate(
model: Project,
createModel: UpdateProjectDTO,
_info?: AppInfoDTO,
): Promise<void> {
if (
createModel.planningUnitAreakm2 &&
createModel.planningUnitGridShape &&
(createModel.countryId ||
createModel.adminAreaLevel1Id ||
createModel.adminAreaLevel2Id ||
createModel.extent)
) {
this.logger.debug('creating planning unit job ');
return this.planningUnitsService.create(createModel);
}
}
}
51 changes: 26 additions & 25 deletions api/apps/api/src/modules/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ import {
Get,
Param,
Patch,
Post,
Query,
Req,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {
Project,
ProjectResultSingular,
projectResource,
ProjectResultPlural,
ProjectResultSingular,
} from './project.api.entity';
import { ProjectsService } from './projects.service';

import {
ApiBearerAuth,
Expand All @@ -28,16 +30,13 @@ import {
} from '@nestjs/swagger';
import { apiGlobalPrefixes } from 'api.config';
import { JwtAuthGuard } from 'guards/jwt-auth.guard';
import { Post } from '@nestjs/common';
import { UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { uploadOptions } from 'utils/file-uploads.utils';

import {
JSONAPIQueryParams,
JSONAPISingleEntityQueryParams,
} from 'decorators/json-api-parameters.decorator';
import { projectResource } from './project.api.entity';
import { UpdateProjectDTO } from './dto/update.project.dto';
import { CreateProjectDTO } from './dto/create.project.dto';
import { RequestWithAuthenticatedUser } from 'app.controller';
Expand All @@ -46,20 +45,20 @@ import {
ProcessFetchSpecification,
} from 'nestjs-base-service';
import { GeoFeatureResult } from 'modules/geo-features/geo-feature.api.entity';
import { GeoFeaturesService } from 'modules/geo-features/geo-features.service';
import { ApiConsumesShapefile } from '../../decorators/shapefile.decorator';
import { Request } from 'express';
import { ProtectedAreasFacade } from './protected-areas/protected-areas.facade';
import { ProjectsService } from './projects.service';
import { GeoFeatureMapper } from './dto/geo-feature.mapper';
import { ProjectMapper } from './dto/project-mapper';

@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiTags(projectResource.className)
@Controller(`${apiGlobalPrefixes.v1}/projects`)
export class ProjectsController {
constructor(
public readonly service: ProjectsService,
private readonly geoFeaturesService: GeoFeaturesService,
private readonly protectedAreaShapefile: ProtectedAreasFacade,
private readonly projectsService: ProjectsService,
private readonly geoFeatureMapper: GeoFeatureMapper,
private readonly projectMapper: ProjectMapper,
) {}

@ApiOperation({
Expand All @@ -77,7 +76,7 @@ export class ProjectsController {
@Param() params: { projectId: string },
@Query('q') featureClassAndAliasFilter: string,
): Promise<GeoFeatureResult> {
const results = await this.geoFeaturesService.findAllPaginated(
const { data, metadata } = await this.projectsService.findAllGeoFeatures(
fetchSpecification,
{
params: {
Expand All @@ -87,7 +86,7 @@ export class ProjectsController {
},
);

return this.geoFeaturesService.serialize(results.data, results.metadata);
return this.geoFeatureMapper.serialize(data, metadata);
}

/**
Expand All @@ -104,7 +103,7 @@ export class ProjectsController {
async importLegacyProject(
@UploadedFile() file: Express.Multer.File,
): Promise<Project> {
return this.service.importLegacyProject(file);
return this.projectsService.importLegacyProject(file);
}

@ApiOperation({
Expand All @@ -125,8 +124,8 @@ export class ProjectsController {
async findAll(
@ProcessFetchSpecification() fetchSpecification: FetchSpecification,
): Promise<ProjectResultPlural> {
const results = await this.service.findAllPaginated(fetchSpecification);
return await this.service.serialize(results.data, results.metadata);
const results = await this.projectsService.findAll(fetchSpecification);
return this.projectMapper.serialize(results.data, results.metadata);
}

@ApiOperation({ description: 'Find project by id' })
Expand All @@ -136,7 +135,9 @@ export class ProjectsController {
})
@Get(':id')
async findOne(@Param('id') id: string): Promise<ProjectResultSingular> {
return await this.service.serialize(await this.service.getById(id));
return await this.projectMapper.serialize(
await this.projectsService.findOne(id),
);
}

@ApiOperation({ description: 'Create project' })
Expand All @@ -146,8 +147,8 @@ export class ProjectsController {
@Body() dto: CreateProjectDTO,
@Req() req: RequestWithAuthenticatedUser,
): Promise<ProjectResultSingular> {
return await this.service.serialize(
await this.service.create(dto, { authenticatedUser: req.user }),
return await this.projectMapper.serialize(
await this.projectsService.create(dto, { authenticatedUser: req.user }),
);
}

Expand All @@ -158,14 +159,16 @@ export class ProjectsController {
@Param('id') id: string,
@Body() dto: UpdateProjectDTO,
): Promise<ProjectResultSingular> {
return await this.service.serialize(await this.service.update(id, dto));
return await this.projectMapper.serialize(
await this.projectsService.update(id, dto),
);
}

@ApiOperation({ description: 'Delete project' })
@ApiOkResponse()
@Delete(':id')
async delete(@Param('id') id: string): Promise<void> {
return await this.service.remove(id);
return await this.projectsService.remove(id);
}

@ApiConsumesShapefile(false)
Expand All @@ -177,11 +180,9 @@ export class ProjectsController {
@Post(':id/protected-areas/shapefile')
async shapefileForProtectedArea(
@Param('id') projectId: string,
@Req() request: Request,
@UploadedFile() file: Express.Multer.File,
): Promise<void> {
// TODO #1 pre-validate project existence

this.protectedAreaShapefile.convert(projectId, request.file);
this.projectsService.addShapeFor(projectId, file);
return;
}
}
Loading

0 comments on commit 98080d2

Please sign in to comment.