diff --git a/.env.local.sample b/.env.local.sample index 80b8389..1b49723 100644 --- a/.env.local.sample +++ b/.env.local.sample @@ -1,7 +1,6 @@ -DOCKER_HOST=unix://$HOME/.docker/run/docker.sock - USER_SERVICE_PORT=4010 JOB_SERVICE_PORT=4020 +RESEARCH_SERVICE_PORT=4030 DB_HOST=localhost DB_PORT=3360 diff --git a/.github/workflows/deploy-service.yml b/.github/workflows/deploy-service.yml index 4bf0a67..98ea53d 100644 --- a/.github/workflows/deploy-service.yml +++ b/.github/workflows/deploy-service.yml @@ -9,8 +9,9 @@ on: type: choice required: true options: - - user-service - job-service + - research-service + - user-service env: description: 'Deployment target environment' type: choice diff --git a/README.md b/README.md index 89cf16b..7e18aaa 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ Repository for the Microservices API backend of the TerraMatch service * On Linux systems, the DOCKER_HOST value should be `unix:///var/run/docker.sock` instead of what's in the sample. * To run all services: * `nx run-many -t serve` + * The default maximum number of services it can run in parallel is 3. To run all of the services at once, use something like + `nx run-many --parallel=100 -t serve`, or you can cherry-pick which services you want to run instead with + `nx run-many -t serve --projects user-service jobs-service`. * In `.env` in your `wri-terramatch-website` repository, set your BE connection URL correctly by noting the config in `.env.local.sample` for local development. * The `NEXT_PUBLIC_API_BASE_URL` still points at the PHP BE directly @@ -38,6 +41,7 @@ and main branches. * In your local web repo, follow directions in `README.md` for setting up a new service. * For deployment to AWS: * Add a Dockerfile in the new app directory. A simple copy and modify from user-service is sufficient + * Add the new service name to the "service" workflow input options in `deploy-service.yml` * In AWS: * Add ECR repositories for each env (follow the naming scheme from user-service, e.g. `terramatch-microservices/foo-service-staging`, etc) * Set the repo to Immutable diff --git a/apps/research-service-e2e/.eslintrc.json b/apps/research-service-e2e/.eslintrc.json new file mode 100644 index 0000000..9d9c0db --- /dev/null +++ b/apps/research-service-e2e/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/research-service-e2e/jest.config.ts b/apps/research-service-e2e/jest.config.ts new file mode 100644 index 0000000..00ac442 --- /dev/null +++ b/apps/research-service-e2e/jest.config.ts @@ -0,0 +1,19 @@ +/* eslint-disable */ +export default { + displayName: 'research-service-e2e', + preset: '../../jest.preset.js', + globalSetup: '/src/support/global-setup.ts', + globalTeardown: '/src/support/global-teardown.ts', + setupFiles: ['/src/support/test-setup.ts'], + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/research-service-e2e', +}; diff --git a/apps/research-service-e2e/project.json b/apps/research-service-e2e/project.json new file mode 100644 index 0000000..c7e9489 --- /dev/null +++ b/apps/research-service-e2e/project.json @@ -0,0 +1,17 @@ +{ + "name": "research-service-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "implicitDependencies": ["research-service"], + "targets": { + "e2e": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{e2eProjectRoot}"], + "options": { + "jestConfig": "apps/research-service-e2e/jest.config.ts", + "passWithNoTests": true + }, + "dependsOn": ["research-service:build"] + } + } +} diff --git a/apps/research-service-e2e/src/research-service/research-service.spec.ts b/apps/research-service-e2e/src/research-service/research-service.spec.ts new file mode 100644 index 0000000..e8ac2a6 --- /dev/null +++ b/apps/research-service-e2e/src/research-service/research-service.spec.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; + +describe('GET /api', () => { + it('should return a message', async () => { + const res = await axios.get(`/api`); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ message: 'Hello API' }); + }); +}); diff --git a/apps/research-service-e2e/src/support/global-setup.ts b/apps/research-service-e2e/src/support/global-setup.ts new file mode 100644 index 0000000..c1f5144 --- /dev/null +++ b/apps/research-service-e2e/src/support/global-setup.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +var __TEARDOWN_MESSAGE__: string; + +module.exports = async function () { + // Start services that that the app needs to run (e.g. database, docker-compose, etc.). + console.log('\nSetting up...\n'); + + // Hint: Use `globalThis` to pass variables to global teardown. + globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n'; +}; diff --git a/apps/research-service-e2e/src/support/global-teardown.ts b/apps/research-service-e2e/src/support/global-teardown.ts new file mode 100644 index 0000000..32ea345 --- /dev/null +++ b/apps/research-service-e2e/src/support/global-teardown.ts @@ -0,0 +1,7 @@ +/* eslint-disable */ + +module.exports = async function () { + // Put clean up logic here (e.g. stopping services, docker-compose, etc.). + // Hint: `globalThis` is shared between setup and teardown. + console.log(globalThis.__TEARDOWN_MESSAGE__); +}; diff --git a/apps/research-service-e2e/src/support/test-setup.ts b/apps/research-service-e2e/src/support/test-setup.ts new file mode 100644 index 0000000..07f2870 --- /dev/null +++ b/apps/research-service-e2e/src/support/test-setup.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ + +import axios from 'axios'; + +module.exports = async function () { + // Configure axios for tests to use. + const host = process.env.HOST ?? 'localhost'; + const port = process.env.PORT ?? '3000'; + axios.defaults.baseURL = `http://${host}:${port}`; +}; diff --git a/apps/research-service-e2e/tsconfig.json b/apps/research-service-e2e/tsconfig.json new file mode 100644 index 0000000..ed633e1 --- /dev/null +++ b/apps/research-service-e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/apps/research-service-e2e/tsconfig.spec.json b/apps/research-service-e2e/tsconfig.spec.json new file mode 100644 index 0000000..d7f9cf2 --- /dev/null +++ b/apps/research-service-e2e/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.ts"] +} diff --git a/apps/research-service/.eslintrc.json b/apps/research-service/.eslintrc.json new file mode 100644 index 0000000..9d9c0db --- /dev/null +++ b/apps/research-service/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/research-service/Dockerfile b/apps/research-service/Dockerfile new file mode 100644 index 0000000..721783b --- /dev/null +++ b/apps/research-service/Dockerfile @@ -0,0 +1,15 @@ +FROM terramatch-microservices-base:nx-base AS builder + +ARG BUILD_FLAG +WORKDIR /app/builder +COPY . . +RUN npx nx build research-service ${BUILD_FLAG} + +FROM terramatch-microservices-base:nx-base + +ARG NODE_ENV +WORKDIR /app +COPY --from=builder /app/builder ./ +ENV NODE_ENV=${NODE_ENV} + +CMD ["node", "./dist/apps/research-service/main.js"] diff --git a/apps/research-service/jest.config.ts b/apps/research-service/jest.config.ts new file mode 100644 index 0000000..a670078 --- /dev/null +++ b/apps/research-service/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'research-service', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/research-service', +}; diff --git a/apps/research-service/project.json b/apps/research-service/project.json new file mode 100644 index 0000000..8e97e47 --- /dev/null +++ b/apps/research-service/project.json @@ -0,0 +1,26 @@ +{ + "name": "research-service", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/research-service/src", + "projectType": "application", + "tags": [], + "targets": { + "serve": { + "executor": "@nx/js:node", + "defaultConfiguration": "development", + "dependsOn": ["build"], + "options": { + "buildTarget": "research-service:build", + "runBuildTargetDependencies": false + }, + "configurations": { + "development": { + "buildTarget": "research-service:build:development" + }, + "production": { + "buildTarget": "research-service:build:production" + } + } + } + } +} diff --git a/apps/research-service/src/app.module.ts b/apps/research-service/src/app.module.ts new file mode 100644 index 0000000..04074eb --- /dev/null +++ b/apps/research-service/src/app.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '@terramatch-microservices/database'; +import { CommonModule } from '@terramatch-microservices/common'; +import { HealthModule } from './health/health.module'; +import { SitePolygonsController } from './site-polygons/site-polygons.controller'; + +@Module({ + imports: [DatabaseModule, CommonModule, HealthModule], + controllers: [SitePolygonsController], + providers: [], +}) +export class AppModule {} diff --git a/apps/research-service/src/assets/.gitkeep b/apps/research-service/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/research-service/src/health/health.controller.ts b/apps/research-service/src/health/health.controller.ts new file mode 100644 index 0000000..12ffbbb --- /dev/null +++ b/apps/research-service/src/health/health.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get } from '@nestjs/common'; +import { + HealthCheck, + HealthCheckService, + SequelizeHealthIndicator, +} from '@nestjs/terminus'; +import { NoBearerAuth } from '@terramatch-microservices/common/guards'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { User } from '@terramatch-microservices/database/entities'; + +@Controller('health') +@ApiExcludeController() +export class HealthController { + constructor( + private readonly health: HealthCheckService, + private readonly db: SequelizeHealthIndicator + ) {} + + @Get() + @HealthCheck() + @NoBearerAuth + async check() { + const connection = await User.sequelize.connectionManager.getConnection({ type: 'read' }); + try { + return this.health.check([ + () => this.db.pingCheck('database', { connection }), + ]); + } finally { + User.sequelize.connectionManager.releaseConnection(connection); + } + } +} diff --git a/apps/research-service/src/health/health.module.ts b/apps/research-service/src/health/health.module.ts new file mode 100644 index 0000000..0208ef7 --- /dev/null +++ b/apps/research-service/src/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/research-service/src/main.ts b/apps/research-service/src/main.ts new file mode 100644 index 0000000..455ac07 --- /dev/null +++ b/apps/research-service/src/main.ts @@ -0,0 +1,31 @@ +import { Logger, ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; + +import { AppModule } from './app.module'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { TMLogService } from '@terramatch-microservices/common/util/tm-log.service'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + const config = new DocumentBuilder() + .setTitle('TerraMatch Research Service') + .setDescription('APIs related to needs for the data research team.') + .setVersion('1.0') + .addTag('research-service') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('research-service/documentation/api', app, document); + + app.useGlobalPipes(new ValidationPipe()); + app.useLogger(app.get(TMLogService)); + + const port = process.env.NODE_ENV === 'production' + ? 80 + : process.env.RESEARCH_SERVICE_PROXY_PORT ?? 4030; + await app.listen(port); + + Logger.log(`TerraMatch Research Service is running on: http://localhost:${port}`); +} + +bootstrap(); diff --git a/apps/research-service/src/site-polygons/dto/indicators.dto.ts b/apps/research-service/src/site-polygons/dto/indicators.dto.ts new file mode 100644 index 0000000..6912692 --- /dev/null +++ b/apps/research-service/src/site-polygons/dto/indicators.dto.ts @@ -0,0 +1,133 @@ +import { ApiProperty } from '@nestjs/swagger'; + +// Matches the indicators defined on https://gfw.atlassian.net/wiki/spaces/TerraMatch/pages/1469448210/Indicator+Data+Model +export const INDICATORS = { + 1: 'treeCover', + 2: 'treeCoverLoss', + 3: 'treeCoverLossFires', + 4: 'restorationByEcoRegion', + 5: 'restorationByStrategy', + 6: 'restorationByLandUse', + 7: 'treeCount', + 8: 'earlyTreeVerification', + 9: 'fieldMonitoring', + 10: 'msuCarbon' +} as const; +export const INDICATOR_SLUGS = Object.values(INDICATORS); +export type IndicatorSlug = (typeof INDICATOR_SLUGS)[number]; + +export class IndicatorTreeCoverLossDto { + @ApiProperty({ enum: [INDICATORS[2], INDICATORS[3]] }) + indicatorSlug: typeof INDICATORS[2] | typeof INDICATORS[3] + + @ApiProperty({ example: '2024' }) + yearOfAnalysis: number; + + @ApiProperty({ + type: 'object', + description: 'Mapping of year of analysis to value.', + example: { 2024: '0.6', 2023: '0.5' } + }) + value: Record; +} + +export class IndicatorHectaresDto { + @ApiProperty({ enum: [INDICATORS[4], INDICATORS[5], INDICATORS[6]] }) + indicatorSlug: typeof INDICATORS[4] | typeof INDICATORS[5] | typeof INDICATORS[6]; + + @ApiProperty({ example: '2024' }) + yearOfAnalysis: number; + + @ApiProperty({ + type: 'object', + description: 'Mapping of area type (eco region, land use, etc) to hectares', + example: { 'Northern Acacia-Commiphora bushlands and thickets': 0.104 } + }) + value: Record; +} + +export class IndicatorTreeCountDto { + @ApiProperty({ enum: [INDICATORS[7], INDICATORS[8]] }) + indicatorSlug: typeof INDICATORS[7] | typeof INDICATORS[8]; + + @ApiProperty({ example: '2024' }) + yearOfAnalysis: number; + + @ApiProperty() + surveyType: string; + + @ApiProperty() + surveyId: number; + + @ApiProperty() + treeCount: number; + + @ApiProperty({ example: 'types TBD' }) + uncertaintyType: string; + + @ApiProperty() + imagerySource: string; + + @ApiProperty({ type: 'url' }) + imageryId: string; + + @ApiProperty() + projectPhase: string; + + @ApiProperty() + confidence: number; +} + +export class IndicatorTreeCoverDto { + @ApiProperty({ enum: [INDICATORS[1]] }) + indicatorSlug: typeof INDICATORS[1]; + + @ApiProperty({ example: '2024' }) + yearOfAnalysis: number; + + @ApiProperty({ example: '2024' }) + projectPhase: string; + + @ApiProperty() + percentCover: number; + + @ApiProperty() + plusMinusPercent: number +} + +export class IndicatorFieldMonitoringDto { + @ApiProperty({ enum: [INDICATORS[9]] }) + indicatorSlug: typeof INDICATORS[9]; + + @ApiProperty({ example: '2024' }) + yearOfAnalysis: number; + + @ApiProperty() + treeCount: number; + + @ApiProperty() + projectPhase: string; + + @ApiProperty() + species: string; + + @ApiProperty() + survivalRate: number; +} + +export class IndicatorMsuCarbonDto { + @ApiProperty({ enum: [INDICATORS[10]] }) + indicatorSlug: typeof INDICATORS[10]; + + @ApiProperty({ example: '2024' }) + yearOfAnalysis: number; + + @ApiProperty() + carbonOutput: number; + + @ApiProperty() + projectPhase: string; + + @ApiProperty() + confidence: number; +} diff --git a/apps/research-service/src/site-polygons/dto/site-polygon-query.dto.ts b/apps/research-service/src/site-polygons/dto/site-polygon-query.dto.ts new file mode 100644 index 0000000..db6c42e --- /dev/null +++ b/apps/research-service/src/site-polygons/dto/site-polygon-query.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { POLYGON_STATUSES, PolygonStatus } from './site-polygon.dto'; +import { INDICATOR_SLUGS, IndicatorSlug } from './indicators.dto'; + +export class SitePolygonQueryDto { + @ApiProperty({ + enum: POLYGON_STATUSES, + name: 'polygonStatus[]', + required: false, + isArray: true, + description: 'Filter results by polygon status', + }) + polygonStatus?: PolygonStatus[]; + + @ApiProperty({ + name: 'projectId[]', + required: false, + isArray: true, + description: 'Filter results by project UUID(s)', + }) + projectId?: string[]; + + @ApiProperty({ + enum: INDICATOR_SLUGS, + name: 'missingIndicator[]', + required: false, + isArray: true, + description: + 'Filter results by polygons that are missing at least one of the indicators listed', + }) + missingIndicator?: IndicatorSlug[]; + + @ApiProperty({ + required: false, + description: + 'Filter results by polygons that have been modified since the date provided', + }) + lastModifiedDate?: Date; + + @ApiProperty({ + required: false, + description: + 'Filter results by polygons that are within the boundary of the polygon referenced by this UUID', + }) + boundaryPolygon?: string; + + @ApiProperty({ + required: false, + name: 'page[size]', + description: 'The size of page being requested', + minimum: 1, + maximum: 100, + default: 100, + }) + pageSize?: number; + + @ApiProperty({ + required: false, + name: 'page[after]', + description: + 'The last record before the page being requested. The value is a polygon UUID. If not provided, the first page is returned.', + }) + pageAfterCursor?: string; +} diff --git a/apps/research-service/src/site-polygons/dto/site-polygon-update.dto.ts b/apps/research-service/src/site-polygons/dto/site-polygon-update.dto.ts new file mode 100644 index 0000000..6e23a0d --- /dev/null +++ b/apps/research-service/src/site-polygons/dto/site-polygon-update.dto.ts @@ -0,0 +1,48 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IndicatorFieldMonitoringDto, + IndicatorHectaresDto, IndicatorMsuCarbonDto, + IndicatorTreeCountDto, IndicatorTreeCoverDto, + IndicatorTreeCoverLossDto +} from './indicators.dto'; + +class SitePolygonUpdateAttributes { + @ApiProperty({ + type: 'array', + items: { + oneOf: [ + { $ref: '#/components/schemas/IndicatorTreeCoverLossDto' }, + { $ref: '#/components/schemas/IndicatorHectaresDto' }, + { $ref: '#/components/schemas/IndicatorTreeCountDto' }, + { $ref: '#/components/schemas/IndicatorTreeCoverDto' }, + { $ref: '#/components/schemas/IndicatorFieldMonitoringDto' }, + { $ref: '#/components/schemas/IndicatorMsuCarbonDto' }, + ] + }, + description: 'All indicators to update for this polygon' + }) + indicators: ( + IndicatorTreeCoverLossDto | + IndicatorHectaresDto | + IndicatorTreeCountDto | + IndicatorTreeCoverDto | + IndicatorFieldMonitoringDto | + IndicatorMsuCarbonDto + )[]; +} + +class SitePolygonUpdate { + @ApiProperty({ enum: ['sitePolygons'] }) + type: 'sitePolygons'; + + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiProperty({ type: () => SitePolygonUpdateAttributes }) + attributes: SitePolygonUpdateAttributes; +} + +export class SitePolygonBulkUpdateBodyDto { + @ApiProperty({ isArray: true, type: () => SitePolygonUpdate }) + data: SitePolygonUpdate[]; +} diff --git a/apps/research-service/src/site-polygons/dto/site-polygon.dto.ts b/apps/research-service/src/site-polygons/dto/site-polygon.dto.ts new file mode 100644 index 0000000..ed97766 --- /dev/null +++ b/apps/research-service/src/site-polygons/dto/site-polygon.dto.ts @@ -0,0 +1,110 @@ +import { JsonApiAttributes } from '@terramatch-microservices/common/dto/json-api-attributes'; +import { JsonApiDto } from '@terramatch-microservices/common/decorators'; +import { ApiProperty } from '@nestjs/swagger'; +import { + IndicatorFieldMonitoringDto, + IndicatorHectaresDto, IndicatorMsuCarbonDto, + IndicatorTreeCountDto, IndicatorTreeCoverDto, + IndicatorTreeCoverLossDto +} from './indicators.dto'; + +class TreeSpecies { + @ApiProperty({ example: 'Acacia binervia' }) + name: string; + + @ApiProperty({ example: 15000 }) + amount: number; +} + +class ReportingPeriod { + @ApiProperty() + dueAt: Date; + + @ApiProperty() + submittedAt: Date; + + @ApiProperty({ + type: () => TreeSpecies, + isArray: true, + description: 'The tree species reported as planted during this reporting period' + }) + treeSpecies: TreeSpecies[]; +} + +export const POLYGON_STATUSES = [ + 'draft', + 'submitted', + 'needs-more-information', + 'approved' +]; +export type PolygonStatus = (typeof POLYGON_STATUSES)[number]; + +@JsonApiDto({ type: 'sitePolygons' }) +export class SitePolygonDto extends JsonApiAttributes { + @ApiProperty() + name: string; + + @ApiProperty({ enum: POLYGON_STATUSES }) + status: PolygonStatus; + + @ApiProperty() + siteId: string; + + @ApiProperty() + plantStart: Date; + + @ApiProperty() + plantEnd: Date; + + @ApiProperty() + practice: string; + + @ApiProperty() + targetSys: string; + + @ApiProperty() + distr: string; + + @ApiProperty() + numTrees: number; + + @ApiProperty() + calcArea: number; + + @ApiProperty({ + type: 'array', + items: { + oneOf: [ + { $ref: '#/components/schemas/IndicatorTreeCoverLossDto' }, + { $ref: '#/components/schemas/IndicatorHectaresDto' }, + { $ref: '#/components/schemas/IndicatorTreeCountDto' }, + { $ref: '#/components/schemas/IndicatorTreeCoverDto' }, + { $ref: '#/components/schemas/IndicatorFieldMonitoringDto' }, + { $ref: '#/components/schemas/IndicatorMsuCarbonDto' }, + ] + }, + description: 'All indicators currently recorded for this site polygon' + }) + indicators: ( + IndicatorTreeCoverLossDto | + IndicatorHectaresDto | + IndicatorTreeCountDto | + IndicatorTreeCoverDto | + IndicatorFieldMonitoringDto | + IndicatorMsuCarbonDto + )[]; + + @ApiProperty({ + type: () => TreeSpecies, + isArray: true, + description: 'The tree species associated with the establishment of the site that this polygon relates to.' + }) + establishmentTreeSpecies: TreeSpecies[]; + + @ApiProperty({ + type: () => ReportingPeriod, + isArray: true, + description: 'Access to reported trees planted for each approved report on this site.' + }) + reportingPeriods: ReportingPeriod[]; +} diff --git a/apps/research-service/src/site-polygons/site-polygons.controller.ts b/apps/research-service/src/site-polygons/site-polygons.controller.ts new file mode 100644 index 0000000..da51b6d --- /dev/null +++ b/apps/research-service/src/site-polygons/site-polygons.controller.ts @@ -0,0 +1,62 @@ +import { + BadRequestException, Body, + Controller, + Get, + NotImplementedException, Patch, + Query, + UnauthorizedException +} from '@nestjs/common'; +import { JsonApiDocument } from '@terramatch-microservices/common/util'; +import { ApiExtraModels, ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator'; +import { JsonApiResponse } from '@terramatch-microservices/common/decorators'; +import { SitePolygonDto } from './dto/site-polygon.dto'; +import { SitePolygonQueryDto } from './dto/site-polygon-query.dto'; +import { + IndicatorFieldMonitoringDto, + IndicatorHectaresDto, + IndicatorMsuCarbonDto, + IndicatorTreeCountDto, + IndicatorTreeCoverDto, + IndicatorTreeCoverLossDto, +} from './dto/indicators.dto'; +import { SitePolygonBulkUpdateBodyDto } from './dto/site-polygon-update.dto'; + +@Controller('research/v3/sitePolygons') +@ApiExtraModels( + IndicatorTreeCoverLossDto, + IndicatorHectaresDto, + IndicatorTreeCountDto, + IndicatorTreeCoverDto, + IndicatorFieldMonitoringDto, + IndicatorMsuCarbonDto +) +export class SitePolygonsController { + @Get() + @ApiOperation({ operationId: 'sitePolygonsIndex', summary: 'Get all site polygons' }) + @JsonApiResponse({ data: { type: SitePolygonDto }, hasMany: true, pagination: true }) + @ApiException(() => UnauthorizedException, { description: 'Authentication failed.' }) + @ApiException(() => BadRequestException, { description: 'Pagination values are invalid.' }) + async findMany( + @Query() query?: SitePolygonQueryDto + ): Promise { + throw new NotImplementedException(); + } + + @Patch() + @ApiOperation({ + operationId: 'bulkUpdateSitePolygons', + summary: 'Update indicators for site polygons', + description: + `If an indicator is provided that already exists, it will be updated with the value in the + payload. If a new indicator is provided, it will be created in the DB. Indicators are keyed + off of the combination of site polygon ID, indicatorSlug, and yearOfAnalysis.` + }) + @ApiOkResponse() + @ApiException(() => UnauthorizedException, { description: 'Authentication failed.' }) + async bulkUpdate( + @Body() updatePayload: SitePolygonBulkUpdateBodyDto + ): Promise { + throw new NotImplementedException(); + } +} diff --git a/apps/research-service/tsconfig.app.json b/apps/research-service/tsconfig.app.json new file mode 100644 index 0000000..a2ce765 --- /dev/null +++ b/apps/research-service/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["node"], + "emitDecoratorMetadata": true, + "target": "es2021" + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/apps/research-service/tsconfig.json b/apps/research-service/tsconfig.json new file mode 100644 index 0000000..c1e2dd4 --- /dev/null +++ b/apps/research-service/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/apps/research-service/tsconfig.spec.json b/apps/research-service/tsconfig.spec.json new file mode 100644 index 0000000..9b2a121 --- /dev/null +++ b/apps/research-service/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/apps/research-service/webpack.config.js b/apps/research-service/webpack.config.js new file mode 100644 index 0000000..316ab88 --- /dev/null +++ b/apps/research-service/webpack.config.js @@ -0,0 +1,20 @@ +const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { join } = require('path'); + +module.exports = { + output: { + path: join(__dirname, '../../dist/apps/research-service'), + }, + plugins: [ + new NxAppWebpackPlugin({ + target: 'node', + compiler: 'tsc', + main: './src/main.ts', + tsConfig: './tsconfig.app.json', + assets: ['./src/assets'], + optimization: false, + outputHashing: 'none', + generatePackageJson: true, + }), + ], +}; diff --git a/cdk/api-gateway/lib/api-gateway-stack.ts b/cdk/api-gateway/lib/api-gateway-stack.ts index 96144c3..dfde559 100644 --- a/cdk/api-gateway/lib/api-gateway-stack.ts +++ b/cdk/api-gateway/lib/api-gateway-stack.ts @@ -22,7 +22,8 @@ import { Stack, StackProps } from 'aws-cdk-lib'; const V3_SERVICES = { 'user-service': ['auth', 'users'], - 'job-service': ['jobs'] + 'job-service': ['jobs'], + 'research-service': ['research'] } const DOMAIN_MAPPINGS: Record = { diff --git a/libs/common/src/lib/decorators/json-api-response.decorator.ts b/libs/common/src/lib/decorators/json-api-response.decorator.ts index 735904a..dc2ba53 100644 --- a/libs/common/src/lib/decorators/json-api-response.decorator.ts +++ b/libs/common/src/lib/decorators/json-api-response.decorator.ts @@ -84,6 +84,11 @@ function constructResource(resource: Resource) { return def; } +function addMeta (document: Document, name: string, definition: any) { + if (document.meta == null) document.meta = { type: "object", properties: {} }; + document.meta.properties[name] = definition; +} + type ResourceType = new (...props: any[]) => JsonApiAttributes; type Relationship = { @@ -105,15 +110,37 @@ type Relationship = { } type Resource = { + /** + * The DTO for the attributes of the resource type + */ type: ResourceType; + relationships?: Relationship[]; } type JsonApiResponseProps = { data: Resource; + + /** + * Set to true if this endpoint returns more than one resource in the main `data` member. + * @default false + */ + hasMany?: boolean; + + /** + * Set to true if this endpoint response documentation should include cursor pagination metadata. + */ + pagination?: boolean; + included?: Resource[]; } +type Document = { + data: any; + meta?: any; + included?: any; +} + /** * Decorator to simplify wrapping the response type from a controller method with the JSON API * response structure. Builds the JSON:API document structure and applies the ApiExtraModels and @@ -122,15 +149,24 @@ type JsonApiResponseProps = { export function JsonApiResponse( options: ApiResponseOptions & JsonApiResponseProps ) { - const { data, included, status, ...rest } = options; + const { data, hasMany, pagination, included, status, ...rest } = options; const extraModels: ResourceType[] = [data.type]; const document = { - data: { - type: "object", - properties: constructResource(data) - } - } as { data: any; included?: any } + data: hasMany + ? { + type: "array", + items: { + type: "object", + properties: constructResource(data) + } + } + : { + type: "object", + properties: constructResource(data) + } + } as Document; + if (included != null && included.length > 0) { for (const includedResource of included) { extraModels.push(includedResource.type); @@ -149,6 +185,16 @@ export function JsonApiResponse( } } + if (pagination) { + addMeta(document, 'page', { + type: "object", + properties: { + cursor: { type: "string", description: "The cursor for the first record on this page." }, + total: { type: "number", description: "The total number of records on this page.", example: 42 } + } + }); + } + const apiResponseOptions = { ...rest, status: status ?? HttpStatus.OK, diff --git a/nx.json b/nx.json index c6f0e37..d11a75f 100644 --- a/nx.json +++ b/nx.json @@ -37,7 +37,8 @@ }, "exclude": [ "apps/user-service-e2e/**/*", - "apps/job-service-e2e/**/*" + "apps/job-service-e2e/**/*", + "apps/research-service-e2e/**/*" ] } ]