diff --git a/.github/workflows/tests-api-e2e.yml b/.github/workflows/tests-api-e2e.yml index 0c2b22099b..5f70fd1c1b 100644 --- a/.github/workflows/tests-api-e2e.yml +++ b/.github/workflows/tests-api-e2e.yml @@ -15,6 +15,7 @@ jobs: timeout-minutes: 60 strategy: fail-fast: false + max-parallel: 6 matrix: test-suite: - 'access-control' diff --git a/.github/workflows/tests-geoprocessing-e2e.yml b/.github/workflows/tests-geoprocessing-e2e.yml index e165eb2edf..d5412088e7 100644 --- a/.github/workflows/tests-geoprocessing-e2e.yml +++ b/.github/workflows/tests-geoprocessing-e2e.yml @@ -15,6 +15,7 @@ jobs: timeout-minutes: 30 strategy: fail-fast: false + max-parallel: 6 matrix: test-suite: - 'cost-template' diff --git a/api/apps/api/src/modules/geo-features/geo-features.service.ts b/api/apps/api/src/modules/geo-features/geo-features.service.ts index aaa2eddfae..812696aa04 100644 --- a/api/apps/api/src/modules/geo-features/geo-features.service.ts +++ b/api/apps/api/src/modules/geo-features/geo-features.service.ts @@ -35,11 +35,17 @@ import { GeoFeaturesRequestInfo } from './geo-features-request-info'; import { antimeridianBbox, nominatim2bbox } from '@marxan/utils/geo'; import { Either, left, right } from 'fp-ts/lib/Either'; import { ProjectAclService } from '@marxan-api/modules/access-control/projects-acl/project-acl.service'; -import { projectNotFound } from '@marxan-api/modules/projects/projects.service'; +import { + projectNotFound, + projectNotVisible, +} from '@marxan-api/modules/projects/projects.service'; import { UpdateFeatureNameDto } from '@marxan-api/modules/geo-features/dto/update-feature-name.dto'; import { ScenarioFeaturesService } from '@marxan-api/modules/scenarios-features'; import { GeoFeatureTag } from '@marxan-api/modules/geo-feature-tags/geo-feature-tag.api.entity'; -import { GeoFeatureTagsService } from '@marxan-api/modules/geo-feature-tags/geo-feature-tags.service'; +import { + featureNotFoundWithinProject, + GeoFeatureTagsService, +} from '@marxan-api/modules/geo-feature-tags/geo-feature-tags.service'; import { FeatureAmountUploadService } from '@marxan-api/modules/geo-features/import/features-amounts-upload.service'; import { isNil } from 'lodash'; @@ -835,8 +841,6 @@ export class GeoFeaturesService extends AppBaseService< } as GeoFeature; } - // @TODO: update tests once saving amounts in puvspr_calculations is consolidates - async saveAmountRangeForFeatures(featureIds: string[]) { this.logger.log(`Saving min and max amounts for new features...`); @@ -868,4 +872,29 @@ export class GeoFeaturesService extends AppBaseService< where features.id = minmax.feature_id;`; await this.geoFeaturesRepository.query(query); } + + async checkProjectFeatureVisibility( + userId: string, + projectId: string, + featureId: string, + ): Promise< + Either< + typeof featureNotFoundWithinProject | typeof projectNotVisible, + GeoFeature + > + > { + const projectFeature = await this.geoFeaturesRepository.findOne({ + where: { id: featureId, projectId }, + }); + + console.log('projectFeature', projectFeature); + if (!projectFeature) { + return left(featureNotFoundWithinProject); + } + if (!(await this.projectAclService.canViewProject(userId, projectId))) { + return left(projectNotVisible); + } + + return right(projectFeature); + } } diff --git a/api/apps/api/src/modules/projects/projects.project-features.controller.ts b/api/apps/api/src/modules/projects/projects.project-features.controller.ts index 604a73b9ef..852ce63d42 100644 --- a/api/apps/api/src/modules/projects/projects.project-features.controller.ts +++ b/api/apps/api/src/modules/projects/projects.project-features.controller.ts @@ -13,6 +13,7 @@ import { Post, Query, Req, + Res, UploadedFile, UseGuards, UseInterceptors, @@ -26,6 +27,7 @@ import { ApiOkResponse, ApiOperation, ApiParam, + ApiQuery, ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; @@ -76,6 +78,8 @@ import { GeoFeatureTagsService } from '@marxan-api/modules/geo-feature-tags/geo- import { GetProjectTagsResponseDto } from '@marxan-api/modules/projects/dto/get-project-tags-response.dto'; import { UpdateProjectTagDTO } from '@marxan-api/modules/projects/dto/update-project-tag.dto'; import { isNil } from 'lodash'; +import { Response } from 'express'; +import { ProxyService } from '@marxan-api/modules/proxy/proxy.service'; @UseGuards(JwtAuthGuard) @ApiBearerAuth() @@ -88,6 +92,7 @@ export class ProjectFeaturesController { private readonly geoFeatureService: GeoFeaturesService, private readonly geoFeatureTagsService: GeoFeatureTagsService, private readonly shapefileService: ShapefileService, + private readonly proxyService: ProxyService, ) {} @IsMissingAclImplementation() @@ -427,4 +432,71 @@ export class ProjectFeaturesController { }); } } + + @ImplementsAcl() + @ApiOperation({ + description: 'Get tile for a project feature by project id and feature id.', + }) + @ApiParam({ + name: 'z', + description: 'The zoom level ranging from 0 - 20', + type: Number, + required: true, + }) + @ApiParam({ + name: 'x', + description: 'The tile x offset on Mercator Projection', + type: Number, + required: true, + }) + @ApiParam({ + name: 'y', + description: 'The tile y offset on Mercator Projection', + type: Number, + required: true, + }) + @ApiParam({ + name: 'projectId', + description: 'Id of the project', + type: String, + required: true, + }) + @ApiParam({ + name: 'featureId', + description: 'Id of the feature', + type: String, + required: true, + }) + @ApiQuery({ + name: 'bbox', + description: 'Bounding box of the project [xMin, xMax, yMin, yMax]', + type: [Number], + required: false, + example: [-1, 40, 1, 42], + }) + @Get(':projectId/features/:featureId/preview/tiles/:z/:x/:y.mvt') + async proxyFeatureTile( + @Req() req: RequestWithAuthenticatedUser, + @Res() response: Response, + @Param('projectId', ParseUUIDPipe) projectId: string, + @Param('featureId', ParseUUIDPipe) featureId: string, + ): Promise { + const checkCostSurfaceForProject = + await this.geoFeatureService.checkProjectFeatureVisibility( + req.user.id, + projectId, + featureId, + ); + console.log('checkCostSurfaceForProject', checkCostSurfaceForProject); + if (isLeft(checkCostSurfaceForProject)) { + throw mapAclDomainToHttpError(checkCostSurfaceForProject.left); + } + + req.url = req.url.replace( + `projects/${projectId}/features`, + `geo-features/project-feature`, + ); + + return await this.proxyService.proxyTileRequest(req, response); + } } diff --git a/api/apps/geoprocessing/src/modules/features/features.controller.ts b/api/apps/geoprocessing/src/modules/features/features.controller.ts index b73a88e323..d3167ea78e 100644 --- a/api/apps/geoprocessing/src/modules/features/features.controller.ts +++ b/api/apps/geoprocessing/src/modules/features/features.controller.ts @@ -73,6 +73,23 @@ export class FeaturesController { ): Promise { const tile: Buffer = await this.service.findTile( TileSpecification, + false, + query.bbox as BBox, + ); + setTileResponseHeadersForSuccessfulRequests(response); + return response.send(tile); + } + + @Get('project-feature/:id/preview/tiles/:z/:x/:y.mvt') + @ApiBadRequestResponse() + async getTileForProjectFeature( + @Param() TileSpecification: TileSpecification, + @Query() query: FeaturesFilters, + @Res() response: Response, + ): Promise { + const tile: Buffer = await this.service.findTile( + TileSpecification, + true, query.bbox as BBox, ); setTileResponseHeadersForSuccessfulRequests(response); diff --git a/api/apps/geoprocessing/src/modules/features/features.service.ts b/api/apps/geoprocessing/src/modules/features/features.service.ts index 7c6c6bebf9..09bd406235 100644 --- a/api/apps/geoprocessing/src/modules/features/features.service.ts +++ b/api/apps/geoprocessing/src/modules/features/features.service.ts @@ -69,12 +69,26 @@ export class FeatureService { */ public findTile( tileSpecification: TileSpecification, + forProject: boolean, bbox?: BBox, ): Promise { const { z, x, y, id } = tileSpecification; const simplificationLevel = 360 / (Math.pow(2, z + 1) * 100); - const attributes = 'feature_id, properties'; - const table = `(select ST_RemoveRepeatedPoints((st_dump(the_geom)).geom, ${simplificationLevel}) as the_geom, (coalesce(properties,'{}'::jsonb) || jsonb_build_object('amount', amount)) as properties, feature_id from "${this.featuresRepository.metadata.tableName}")`; + const attributes = forProject + ? 'feature_id, amount' + : 'feature_id, properties'; + const table = forProject + ? `(SELECT ST_RemoveRepeatedPoints((st_dump(the_geom)).geom, ${simplificationLevel}) AS the_geom, + amount, + feature_id + FROM puvspr_calculations + INNER JOIN projects_pu ppu on ppu.id=puvspr_calculations.project_pu_id + INNER JOIN planning_units_geom pug on pug.id=ppu.geom_id)` + : `(select ST_RemoveRepeatedPoints((st_dump(the_geom)).geom, ${simplificationLevel}) as the_geom, + (coalesce(properties,'{}'::jsonb) || jsonb_build_object('amount', amount)) as properties, + feature_id + from "${this.featuresRepository.metadata.tableName}")`; + const customQuery = this.buildFeaturesWhereQuery(id, bbox); return this.tileService.getTile({ z, diff --git a/app/components/modal/component.tsx b/app/components/modal/component.tsx index 7688938e14..234b97e832 100644 --- a/app/components/modal/component.tsx +++ b/app/components/modal/component.tsx @@ -22,7 +22,7 @@ const CONTENT_CLASSES = { wide: `w-full sm:w-10/12 md:w-10/12 lg:w-10/12 xl:w-9/12 2xl:w-6/12 ${COMMON_CONTENT_CLASSES}`, }; -const OVERLAY_CLASSES = 'z-50 fixed inset-0 bg-black bg-blur'; +const OVERLAY_CLASSES = 'z-40 fixed inset-0 bg-black bg-blur'; export const Modal: React.FC = ({ id, diff --git a/app/layout/projects/new/form/component.tsx b/app/layout/projects/new/form/component.tsx index 15f3418606..57f40f0a91 100644 --- a/app/layout/projects/new/form/component.tsx +++ b/app/layout/projects/new/form/component.tsx @@ -92,6 +92,17 @@ const ProjectForm = ({ onFormUpdate }: ProjectFormProps): JSX.Element => { organizationId: organizationsData[0].id || '7f1fb7f8-1246-4509-89b9-f48b6f976e3f', } satisfies NewProjectFields & { organizationId: string }; + addToast( + 'info-project-creation', + <> +

Your project is being created.

+

This might take a few seconds.

+ , + { + level: 'info', + } + ); + saveProjectMutation.mutate( { data }, { @@ -99,8 +110,8 @@ const ProjectForm = ({ onFormUpdate }: ProjectFormProps): JSX.Element => { addToast( 'success-project-creation', <> -

Success!

-

Project saved successfully

+

Your project has been created.

+

You will be redirected to the dashboard.

, { level: 'success', diff --git a/app/layout/projects/new/form/planning-area-grid-uploader/component.tsx b/app/layout/projects/new/form/planning-area-grid-uploader/component.tsx index 3748bf309d..3f28ab992e 100644 --- a/app/layout/projects/new/form/planning-area-grid-uploader/component.tsx +++ b/app/layout/projects/new/form/planning-area-grid-uploader/component.tsx @@ -309,6 +309,7 @@ export const PlanningAreaGridUploader: React.FC = size="xl" type="submit" onClick={() => setOpened(false)} + disabled={loading} > Save diff --git a/e2e-product-testing/yarn.lock b/e2e-product-testing/yarn.lock index 3c10331fcd..fcb32d539b 100644 --- a/e2e-product-testing/yarn.lock +++ b/e2e-product-testing/yarn.lock @@ -2553,9 +2553,9 @@ get-assigned-identifiers@^1.2.0: integrity sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ== get-func-name@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" - integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= + version "2.0.2" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" + integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== get-intrinsic@^1.0.2: version "1.1.1"