Skip to content

Commit

Permalink
feat(projects): allow projects to be public
Browse files Browse the repository at this point in the history
  • Loading branch information
kgajowy committed Aug 25, 2021
1 parent 8b4d816 commit f234d8c
Show file tree
Hide file tree
Showing 10 changed files with 239 additions and 55 deletions.
15 changes: 15 additions & 0 deletions api/apps/api/src/migrations/api/1629877107141-PublicProjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class PublicProjects1629877107141 implements MigrationInterface {
name = 'PublicProjects1629877107141';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "projects"
ADD "is_public" boolean NOT NULL DEFAULT false`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "projects"
DROP COLUMN "is_public"`);
}
}
7 changes: 7 additions & 0 deletions api/apps/api/src/modules/projects/project.api.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export class Project extends TimeUserEntityMetadata {
@Column('character varying')
description?: string;

@Column({
type: `boolean`,
name: `is_public`,
default: false,
})
isPublic!: boolean;

/**
* The organization to which this scenario belongs.
*/
Expand Down
42 changes: 24 additions & 18 deletions api/apps/api/src/modules/projects/projects-crud.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { assertDefined, isDefined } from '@marxan/utils';
import { InjectRepository } from '@nestjs/typeorm';
import { AppInfoDTO } from '@marxan-api/dto/info.dto';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Project } from './project.api.entity';
import { CreateProjectDTO } from './dto/create.project.dto';
Expand All @@ -25,6 +24,7 @@ import {
} from './planning-areas';
import { UsersProjectsApiEntity } from './control-level/users-projects.api.entity';
import { Roles } from '@marxan-api/modules/users/role.api.entity';
import { AppInfoDTO } from '@marxan-api/dto/info.dto';

const projectFilterKeyNames = [
'name',
Expand All @@ -50,7 +50,7 @@ export class ProjectsCrudService extends AppBaseService<
Project,
CreateProjectDTO,
UpdateProjectDTO,
AppInfoDTO
ProjectsInfoDTO
> {
constructor(
@InjectRepository(Project)
Expand Down Expand Up @@ -126,7 +126,7 @@ export class ProjectsCrudService extends AppBaseService<
setFilters(
query: SelectQueryBuilder<Project>,
filters: ProjectFilters,
_info?: AppInfoDTO,
_info?: ProjectsInfoDTO,
): SelectQueryBuilder<Project> {
this._processBaseFilters<ProjectFilters>(
query,
Expand All @@ -138,16 +138,17 @@ export class ProjectsCrudService extends AppBaseService<

async setDataCreate(
create: CreateProjectDTO,
info?: AppInfoDTO,
info?: ProjectsInfoDTO,
): Promise<Project> {
assertDefined(info?.authenticatedUser?.id);
/**
* @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!;
project.createdBy = info.authenticatedUser?.id;
project.planningAreaGeometryId = create.planningAreaId;

const bbox = await this.planningAreasService.getPlanningAreaBBox({
Expand All @@ -174,7 +175,7 @@ export class ProjectsCrudService extends AppBaseService<
async actionAfterCreate(
model: Project,
createModel: CreateProjectDTO,
_info?: AppInfoDTO,
_info?: ProjectsInfoDTO,
): Promise<void> {
if (
createModel.planningUnitAreakm2 &&
Expand All @@ -200,7 +201,7 @@ export class ProjectsCrudService extends AppBaseService<
async actionAfterUpdate(
model: Project,
createModel: UpdateProjectDTO,
_info?: AppInfoDTO,
_info?: ProjectsInfoDTO,
): Promise<void> {
/**
* @deprecated Workers and jobs should be move to the new functionality
Expand All @@ -227,7 +228,7 @@ export class ProjectsCrudService extends AppBaseService<
async setDataUpdate(
model: Project,
update: UpdateProjectDTO,
_?: AppInfoDTO,
_?: ProjectsInfoDTO,
): Promise<Project> {
const bbox = await this.planningAreasService.getPlanningAreaBBox({
...update,
Expand All @@ -246,7 +247,7 @@ export class ProjectsCrudService extends AppBaseService<
async extendGetByIdResult(
entity: Project,
_fetchSpecification?: FetchSpecification,
_info?: AppInfoDTO,
_info?: ProjectsInfoDTO,
): Promise<Project> {
const ids: MultiplePlanningAreaIds = entity;
const idAndName = await this.planningAreasService.getPlanningAreaIdAndName(
Expand All @@ -264,7 +265,8 @@ export class ProjectsCrudService extends AppBaseService<
fetchSpecification: FetchSpecification,
info?: ProjectsInfoDTO,
): Promise<SelectQueryBuilder<Project>> {
assertDefined(info?.authenticatedUser);
const loggedUser = Boolean(info?.authenticatedUser);

const { namesSearch } = info?.params ?? {};

query.leftJoin(
Expand Down Expand Up @@ -294,13 +296,17 @@ export class ProjectsCrudService extends AppBaseService<
);
}

query
.andWhere(`acl.user_id = :userId`, {
userId: info?.authenticatedUser?.id,
})
.andWhere(`acl.role_id = :roleId`, {
roleId: Roles.project_owner,
});
if (loggedUser) {
query
.andWhere(`acl.user_id = :userId`, {
userId: info?.authenticatedUser?.id,
})
.andWhere(`acl.role_id = :roleId`, {
roleId: Roles.project_owner,
});
} else {
query.andWhere(`${this.alias}.is_public = true`);
}

return query;
}
Expand All @@ -323,7 +329,7 @@ export class ProjectsCrudService extends AppBaseService<
async extendFindAllResults(
entitiesAndCount: [Project[], number],
_fetchSpecification?: FetchSpecification,
_info?: AppInfoDTO,
_info?: ProjectsInfoDTO,
): Promise<[Project[], number]> {
const extendedEntities: Promise<Project>[] = entitiesAndCount[0].map(
(entity) => this.extendGetByIdResult(entity),
Expand Down
95 changes: 95 additions & 0 deletions api/apps/api/src/modules/projects/projects-listing.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
applyDecorators,
Controller,
Get,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import {
FetchSpecification,
ProcessFetchSpecification,
} from 'nestjs-base-service';

import { JSONAPIQueryParams } from '@marxan-api/decorators/json-api-parameters.decorator';
import { RequestWithAuthenticatedUser } from '@marxan-api/app.controller';
import { apiGlobalPrefixes } from '@marxan-api/api.config';
import { JwtAuthGuard } from '@marxan-api/guards/jwt-auth.guard';

import { projectResource, ProjectResultPlural } from './project.api.entity';
import { ProjectsService } from './projects.service';
import { ProjectSerializer } from './dto/project.serializer';

@ApiTags(projectResource.className)
@Controller(`${apiGlobalPrefixes.v1}/projects`)
export class ProjectsListingController {
constructor(
private readonly projectsService: ProjectsService,
private readonly projectSerializer: ProjectSerializer,
) {}

@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ProjectsListing()
@Get()
async findAll(
@ProcessFetchSpecification() fetchSpecification: FetchSpecification,
@Req() req: RequestWithAuthenticatedUser,
@Query('q') namesSearch?: string,
): Promise<ProjectResultPlural> {
const results = await this.projectsService.findAll(fetchSpecification, {
params: {
namesSearch,
},
authenticatedUser: req.user,
});
return this.projectSerializer.serialize(results.data, results.metadata);
}

@ProjectsListing()
@Get(`published`)
async findAllPublic(
@ProcessFetchSpecification() fetchSpecification: FetchSpecification,
@Query('q') namesSearch?: string,
): Promise<ProjectResultPlural> {
const results = await this.projectsService.findAll(fetchSpecification, {
params: {
namesSearch,
},
});
return this.projectSerializer.serialize(results.data, results.metadata);
}
}

function ProjectsListing() {
return applyDecorators(
...[
ApiOperation({
description: 'Find all projects',
}),
ApiOkResponse({ type: ProjectResultPlural }),
JSONAPIQueryParams({
entitiesAllowedAsIncludes: projectResource.entitiesAllowedAsIncludes,
availableFilters: [
{ name: 'name' },
{ name: 'organizationId' },
{ name: 'countryId' },
{ name: 'adminAreaLevel1Id' },
{ name: 'adminAreaLevel21Id' },
],
}),
ApiQuery({
name: 'q',
required: false,
description: `A free search over names`,
}),
],
);
}
36 changes: 0 additions & 36 deletions api/apps/api/src/modules/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
import {
Project,
projectResource,
ProjectResultPlural,
ProjectResultSingular,
} from './project.api.entity';

Expand All @@ -28,7 +27,6 @@ import {
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiQuery,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
Expand Down Expand Up @@ -115,40 +113,6 @@ export class ProjectsController {
return this.projectsService.importLegacyProject(file);
}

@ApiOperation({
description: 'Find all projects',
})
@ApiOkResponse({ type: ProjectResultPlural })
@JSONAPIQueryParams({
entitiesAllowedAsIncludes: projectResource.entitiesAllowedAsIncludes,
availableFilters: [
{ name: 'name' },
{ name: 'organizationId' },
{ name: 'countryId' },
{ name: 'adminAreaLevel1Id' },
{ name: 'adminAreaLevel21Id' },
],
})
@ApiQuery({
name: 'q',
required: false,
description: `A free search over names`,
})
@Get()
async findAll(
@ProcessFetchSpecification() fetchSpecification: FetchSpecification,
@Req() req: RequestWithAuthenticatedUser,
@Query('q') namesSearch?: string,
): Promise<ProjectResultPlural> {
const results = await this.projectsService.findAll(fetchSpecification, {
params: {
namesSearch,
},
authenticatedUser: req.user,
});
return this.projectSerializer.serialize(results.data, results.metadata);
}

@ApiOperation({ description: 'Find project by id' })
@ApiOkResponse({ type: ProjectResultSingular })
@JSONAPISingleEntityQueryParams({
Expand Down
7 changes: 6 additions & 1 deletion api/apps/api/src/modules/projects/projects.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { JobStatusService } from './job-status/job-status.service';
import { ScenarioJobStatus } from './job-status/job-status.view.api.entity';
import { PlanningAreasModule } from './planning-areas';
import { UsersProjectsApiEntity } from './control-level/users-projects.api.entity';
import { ProjectsListingController } from './projects-listing.controller';

@Module({
imports: [
Expand All @@ -46,7 +47,11 @@ import { UsersProjectsApiEntity } from './control-level/users-projects.api.entit
JobStatusService,
JobStatusSerializer,
],
controllers: [ProjectsController],
/**
* Order is important due to `GET projects/published` clash with
* `GET projects/:id`
*/
controllers: [ProjectsListingController, ProjectsController],
exports: [ProjectsCrudService],
})
export class ProjectsModule {}
4 changes: 4 additions & 0 deletions api/apps/api/src/modules/projects/projects.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export class ProjectsService {
}

async findAll(fetchSpec: FetchSpecification, info?: ProjectsInfoDTO) {
return this.projectsCrud.findAllPaginated(fetchSpec, info);
}

async findAllPublic(fetchSpec: FetchSpecification, info?: ProjectsInfoDTO) {
// /ACL slot/
return this.projectsCrud.findAllPaginated(fetchSpec, info);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ async function getFixtures() {
createdBy: '',
countryId: '',
bbox: [0, 0, 0, 0, 0, 0],
isPublic: false,
},
status: JobStatus.done,
type: ScenarioType.marxanWithZones,
Expand Down
24 changes: 24 additions & 0 deletions api/apps/api/test/project/public-projects.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { FixtureType } from '@marxan/utils/tests/fixture-type';
import { getFixtures } from './public-projects.fixtures';

let fixtures: FixtureType<typeof getFixtures>;

beforeEach(async () => {
fixtures = await getFixtures();
});
afterEach(async () => {
await fixtures?.cleanup();
});

test(`getting public projects while none is available`, async () => {
await fixtures.GivenPrivateProjectWasCreated();
const response = await fixtures.WhenGettingPublicProjects();
fixtures.ThenNoProjectIsAvailable(response);
});

test(`getting public projects`, async () => {
const publicProjectId = await fixtures.GivenPublicProjectWasCreated();
await fixtures.GivenPrivateProjectWasCreated();
const response = await fixtures.WhenGettingPublicProjects();
fixtures.ThenPublicProjectIsAvailable(publicProjectId, response);
});
Loading

0 comments on commit f234d8c

Please sign in to comment.