diff --git a/.env.local.sample b/.env.local.sample index 6758390a..75a6a3ea 100644 --- a/.env.local.sample +++ b/.env.local.sample @@ -15,6 +15,9 @@ DB_PASSWORD=wri REDIS_HOST=localhost REDIS_PORT=6379 +# Shared Data API key that may be used by all devs and AWS environments +DATA_API_KEY=349c85f6-a915-4d3d-b337-0a0dafc0e5db + JWT_SECRET=qu3sep4GKdbg6PiVPCKLKljHukXALorq6nLHDBOCSwvs6BrgE6zb8gPmZfrNspKt AWS_ENDPOINT==http://minio:9000 @@ -28,15 +31,17 @@ AIRTABLE_BASE_ID= SLACK_API_KEY= UDB_SLACK_CHANNEL= -MAIL_HOST=email-smtp.eu-west-1.amazonaws.com -MAIL_PORT=587 -MAIL_USERNAME= -MAIL_PASSWORD= -MAIL_FROM_ADDRESS=staging@terramatch.org +# Points to the mail catcher running from the PHP docker-compose. Visit localhost:1080 to see "sent" emails. +MAIL_HOST=localhost +MAIL_PORT=1025 +MAIL_FROM_ADDRESS=local@terramatch.org # If set, email is redirected from the original recipient to this list of addresses. The original # recipients are encoded in the X-Original-Recipients header MAIL_RECIPIENTS= +# If set, email addresses on this list will be exempted from receiving updates to entity statuses +ENTITY_UPDATE_DO_NOT_EMAIL= TRANSIFEX_TOKEN=1/dada8aab9ed6bba68805fcab6349f2e69d3b1367 EMAIL_IMAGE_BASE_URL=https://api.terramatch.org/images +APP_FRONT_END=http://localhost:3000 diff --git a/.github/workflows/deploy-service.yml b/.github/workflows/deploy-service.yml index ccb7b48c..594554c1 100644 --- a/.github/workflows/deploy-service.yml +++ b/.github/workflows/deploy-service.yml @@ -78,6 +78,7 @@ jobs: echo "AIRTABLE_API_KEY=\"${{ secrets.AIRTABLE_API_KEY }}\"" >> .env echo "SENTRY_DSN=\"${{ secrets.SENTRY_DSN }}\"" >> .env echo "SLACK_API_KEY=\"${{ secrets.SLACK_API_KEY }}\"" >> .env + echo "ENTITY_UPDATE_DO_NOT_EMAIL=\"${{ secrets.ENTITY_UPDATE_DO_NOT_EMAIL }}\"" >> .env : # Don't build the base image with NODE_ENV because it'll limit the packages that are installed docker build -t terramatch-microservices-base:nx-base . SERVICE_IMAGE=$ECR_REGISTRY/$ECR_REPOSITORY:${{ env.IMAGE_TAG }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index bfe0f591..45a352ba 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -46,7 +46,7 @@ jobs: # First run just the small database test to get the test database synced to the current schema # in a clean way. - name: Sync DB Schema - run: npx nx test database --no-cloud --skip-nx-cache + run: npx nx test database --no-cloud --skip-nx-cache libs/database/src/lib/database.module.spec.ts # All tests are run in band so that wipes of database tables from one test doesn't interfere # with other tests. diff --git a/README.md b/README.md index 5990b35e..ebf44db1 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,29 @@ to take effect. Once this project is live in production, we can explore continuous deployment to at least staging and prod envs on the staging and main branches. +# Environment + +The Environment for a given service deployment is configured in Github Actions secrets / variables. Some are repo-wide, and +some apply only to a given environment. During the build process, the contents of the variables applied to .env are visible +to the general public, so we need to be very careful about what is included there. Nothing sensitive (passwords, email +addresses, API tokens, etc) may be included in Variables, and must instead be in Secrets + +- If you need to update a _non-secret_ ENV variable, add / update it in the given environment's ENV variable +- If you need to add a _secret_ ENV variable, create the secret in Github actions, and then add a line to `deploy-service.yml` + to append that secret to the generated `.env` variable. +- The current value of secrets in GitHub actions may not be read by anyone, including repository admins. If you need to + inspect the current value of a configured secret, the recommended approach is to access that deployed service's REPL, and + pull the value using the `ConfigService`: + +``` +> $(ConfigService).get("SUPER_SECRETE_ENV_VALUE"); +``` + # Creating a new service - In the root directory: `nx g @nx/nest:app apps/foo-service` - Set up the new `main.ts` similarly to existing services. - - Make sure swagger docs and the `/health` endpoint are implemented + - Make sure swagger docs are implemented - Pick a default local port that is unique from other services - Make sure the top of `main.ts` has these two lines: ``` @@ -95,6 +113,7 @@ and main branches. import "../../../instrument-sentry"; ``` - Add the `SentryModule` and `SentryGlobalFilter` to your main `app.module.ts`. See an existing service for an example. + - Add the `HealthModule` to your main `app.module.ts`. You will likely need `CommonModule` as well. - Set up REPL access: - Copy `repl.ts` from an existing service (and modify to specify the new service's name) - Add the `build-repl` target to `project.json`, which an empty definition. diff --git a/apps/entity-service/src/app.module.ts b/apps/entity-service/src/app.module.ts index 748bbb5e..006c28ac 100644 --- a/apps/entity-service/src/app.module.ts +++ b/apps/entity-service/src/app.module.ts @@ -1,7 +1,5 @@ import { Module } from "@nestjs/common"; -import { DatabaseModule } from "@terramatch-microservices/database"; import { CommonModule } from "@terramatch-microservices/common"; -import { HealthModule } from "./health/health.module"; import { TreesController } from "./trees/trees.controller"; import { TreeService } from "./trees/tree.service"; import { SentryGlobalFilter, SentryModule } from "@sentry/nestjs/setup"; @@ -9,9 +7,10 @@ import { APP_FILTER } from "@nestjs/core"; import { EntitiesService } from "./entities/entities.service"; import { EntitiesController } from "./entities/entities.controller"; import { EntityAssociationsController } from "./entities/entity-associations.controller"; +import { HealthModule } from "@terramatch-microservices/common/health/health.module"; @Module({ - imports: [SentryModule.forRoot(), DatabaseModule, CommonModule, HealthModule], + imports: [SentryModule.forRoot(), CommonModule, HealthModule], controllers: [EntitiesController, EntityAssociationsController, TreesController], providers: [ { diff --git a/apps/entity-service/src/entities/dto/disturbance.dto.ts b/apps/entity-service/src/entities/dto/disturbance.dto.ts new file mode 100644 index 00000000..e1c20e36 --- /dev/null +++ b/apps/entity-service/src/entities/dto/disturbance.dto.ts @@ -0,0 +1,30 @@ +import { JsonApiDto } from "@terramatch-microservices/common/decorators"; +import { pickApiProperties } from "@terramatch-microservices/common/dto/json-api-attributes"; +import { ApiProperty } from "@nestjs/swagger"; +import { Disturbance } from "@terramatch-microservices/database/entities/disturbance.entity"; +import { AssociationDto, AssociationDtoAdditionalProps } from "./association.dto"; + +@JsonApiDto({ type: "disturbances" }) +export class DisturbanceDto extends AssociationDto { + constructor(disturbance: Disturbance, additional: AssociationDtoAdditionalProps) { + super({ + ...pickApiProperties(disturbance, DisturbanceDto), + ...additional + }); + } + + @ApiProperty({ nullable: true }) + collection: string | null; + + @ApiProperty({ nullable: true }) + type: string | null; + + @ApiProperty({ nullable: true }) + intensity: string | null; + + @ApiProperty({ nullable: true }) + extent: string | null; + + @ApiProperty({ nullable: true }) + description: string | null; +} diff --git a/apps/entity-service/src/entities/dto/entity-query.dto.ts b/apps/entity-service/src/entities/dto/entity-query.dto.ts index bd39a986..836dd11e 100644 --- a/apps/entity-service/src/entities/dto/entity-query.dto.ts +++ b/apps/entity-service/src/entities/dto/entity-query.dto.ts @@ -4,12 +4,16 @@ import { NumberPage } from "@terramatch-microservices/common/dto/page.dto"; import { MAX_PAGE_SIZE, PROCESSABLE_ENTITIES, - ProcessableEntity, + PROCESSABLE_ASSOCIATIONS, POLYGON_STATUSES_FILTERS, PolygonStatusFilter } from "../entities.service"; import { Type } from "class-transformer"; +export const VALID_SIDELOAD_TYPES = [...PROCESSABLE_ENTITIES, ...PROCESSABLE_ASSOCIATIONS] as const; + +export type SideloadType = (typeof VALID_SIDELOAD_TYPES)[number]; + class QuerySort { @ApiProperty({ name: "sort[field]", required: false }) @IsOptional() @@ -22,9 +26,13 @@ class QuerySort { } export class EntitySideload { - @IsIn(PROCESSABLE_ENTITIES) - @ApiProperty({ name: "entity", enum: PROCESSABLE_ENTITIES, description: "Entity type to sideload" }) - entity: ProcessableEntity; + @IsIn(VALID_SIDELOAD_TYPES) + @ApiProperty({ + name: "entity", + enum: VALID_SIDELOAD_TYPES, + description: "Entity or association type to sideload" + }) + entity: SideloadType; @ApiProperty({ name: "pageSize", description: "The page size to include." }) @IsInt() diff --git a/apps/entity-service/src/entities/dto/entity-update.dto.ts b/apps/entity-service/src/entities/dto/entity-update.dto.ts new file mode 100644 index 00000000..6dc40ffc --- /dev/null +++ b/apps/entity-service/src/entities/dto/entity-update.dto.ts @@ -0,0 +1,87 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { ENTITY_STATUSES, REPORT_STATUSES, SITE_STATUSES } from "@terramatch-microservices/database/constants/status"; +import { IsArray, IsBoolean, IsIn, IsOptional, IsString } from "class-validator"; +import { JsonApiDataDto, JsonApiMultiBodyDto } from "@terramatch-microservices/common/util/json-api-update-dto"; +import { Type } from "class-transformer"; + +export class EntityUpdateAttributes { + @IsOptional() + @IsIn(ENTITY_STATUSES) + @ApiProperty({ + description: "Request to change to the status of the given entity", + nullable: true, + enum: ENTITY_STATUSES + }) + status?: string | null; + + @IsOptional() + @IsString() + @ApiProperty({ description: "Specific feedback for the PD", nullable: true }) + feedback?: string | null; + + @IsOptional() + @IsArray() + @Type(() => String) + @ApiProperty({ + isArray: true, + type: String, + description: "The fields in the entity form that need attention from the PD", + nullable: true + }) + feedbackFields?: string[] | null; +} + +export class ProjectUpdateAttributes extends EntityUpdateAttributes { + @IsOptional() + @IsBoolean() + @ApiProperty({ description: "Update the isTest flag.", nullable: true }) + isTest?: boolean; +} + +export class SiteUpdateAttributes extends EntityUpdateAttributes { + @IsOptional() + @IsIn(SITE_STATUSES) + @ApiProperty({ + description: "Request to change to the status of the given site", + nullable: true, + enum: SITE_STATUSES + }) + status?: string | null; +} + +export class ReportUpdateAttributes extends EntityUpdateAttributes { + @IsOptional() + @IsIn(REPORT_STATUSES) + @ApiProperty({ + description: "Request to change to the status of the given report", + nullable: true, + enum: REPORT_STATUSES + }) + status?: string | null; + + @IsOptional() + @IsBoolean() + @ApiProperty({ description: "Update the nothingToReport flag.", nullable: true }) + nothingToReport?: boolean; +} + +export class ProjectUpdateData extends JsonApiDataDto({ type: "projects" }, ProjectUpdateAttributes) {} +export class SiteUpdateData extends JsonApiDataDto({ type: "sites" }, SiteUpdateAttributes) {} +export class NurseryUpdateData extends JsonApiDataDto({ type: "nurseries" }, EntityUpdateAttributes) {} +export class ProjectReportUpdateData extends JsonApiDataDto({ type: "projectReports" }, ReportUpdateAttributes) {} +export class SiteReportUpdateData extends JsonApiDataDto({ type: "siteReports" }, ReportUpdateAttributes) {} +export class NurseryReportUpdateData extends JsonApiDataDto({ type: "nurseryReports" }, ReportUpdateAttributes) {} + +export type EntityUpdateData = + | ProjectUpdateAttributes + | SiteUpdateAttributes + | ReportUpdateAttributes + | EntityUpdateAttributes; +export class EntityUpdateBody extends JsonApiMultiBodyDto([ + ProjectUpdateData, + SiteUpdateData, + NurseryUpdateData, + ProjectReportUpdateData, + SiteReportUpdateData, + NurseryReportUpdateData +] as const) {} diff --git a/apps/entity-service/src/entities/dto/entity.dto.ts b/apps/entity-service/src/entities/dto/entity.dto.ts index aa8a898c..43ebbc41 100644 --- a/apps/entity-service/src/entities/dto/entity.dto.ts +++ b/apps/entity-service/src/entities/dto/entity.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty } from "@nestjs/swagger"; import { HybridSupportDto } from "@terramatch-microservices/common/dto/hybrid-support.dto"; +import { JsonApiConstants } from "@terramatch-microservices/common/decorators/json-api-constants.decorator"; +import { ENTITY_TYPES } from "@terramatch-microservices/database/constants/entities"; /** * A utility type for constructing the "extra props" type of a DTO based on what's in the dto, the @@ -7,6 +9,12 @@ import { HybridSupportDto } from "@terramatch-microservices/common/dto/hybrid-su */ export type AdditionalProps = Pick>; +@JsonApiConstants +export class SupportedEntities { + @ApiProperty({ example: ENTITY_TYPES }) + ENTITY_TYPES: string[]; +} + export abstract class EntityDto extends HybridSupportDto { /** * All EntityDtos must include UUID in the attributes for use in the react-admin pagination diff --git a/apps/entity-service/src/entities/dto/invasive.dto.ts b/apps/entity-service/src/entities/dto/invasive.dto.ts new file mode 100644 index 00000000..6cde2a43 --- /dev/null +++ b/apps/entity-service/src/entities/dto/invasive.dto.ts @@ -0,0 +1,21 @@ +import { JsonApiDto } from "@terramatch-microservices/common/decorators"; +import { pickApiProperties } from "@terramatch-microservices/common/dto/json-api-attributes"; +import { ApiProperty } from "@nestjs/swagger"; +import { Invasive } from "@terramatch-microservices/database/entities/invasive.entity"; +import { AssociationDto, AssociationDtoAdditionalProps } from "./association.dto"; + +@JsonApiDto({ type: "invasives" }) +export class InvasiveDto extends AssociationDto { + constructor(invasive: Invasive, additional: AssociationDtoAdditionalProps) { + super({ + ...pickApiProperties(invasive, InvasiveDto), + ...additional + }); + } + + @ApiProperty({ nullable: true }) + type: string | null; + + @ApiProperty({ nullable: true }) + name: string | null; +} diff --git a/apps/entity-service/src/entities/dto/nursery-report.dto.ts b/apps/entity-service/src/entities/dto/nursery-report.dto.ts index a7d204ce..763fda8b 100644 --- a/apps/entity-service/src/entities/dto/nursery-report.dto.ts +++ b/apps/entity-service/src/entities/dto/nursery-report.dto.ts @@ -89,6 +89,9 @@ export class NurseryReportLightDto extends EntityDto { @ApiProperty() createdAt: Date; + + @ApiProperty({ nullable: true }) + nothingToReport: boolean | null; } export type AdditionalNurseryReportLightProps = Pick; @@ -156,9 +159,6 @@ export class NurseryReportFullDto extends NurseryReportLightDto { @ApiProperty({ nullable: true }) feedbackFields: string[] | null; - @ApiProperty() - nothingToReport: boolean; - @ApiProperty({ nullable: true }) completion: number | null; diff --git a/apps/entity-service/src/entities/dto/project-report.dto.ts b/apps/entity-service/src/entities/dto/project-report.dto.ts index 9c025da9..2000236e 100644 --- a/apps/entity-service/src/entities/dto/project-report.dto.ts +++ b/apps/entity-service/src/entities/dto/project-report.dto.ts @@ -71,6 +71,9 @@ export class ProjectReportLightDto extends EntityDto { @ApiProperty() updatedAt: Date; + + @ApiProperty({ nullable: true }) + pctSurvivalToDate: number | null; } export type AdditionalProjectReportFullProps = AdditionalProps< @@ -188,9 +191,6 @@ export class ProjectReportFullDto extends ProjectReportLightDto { @ApiProperty({ nullable: true }) significantChange: string | null; - @ApiProperty({ nullable: true }) - pctSurvivalToDate: number | null; - @ApiProperty({ nullable: true }) survivalCalculation: string | null; @@ -304,4 +304,52 @@ export class ProjectReportFullDto extends ProjectReportLightDto { @ApiProperty({ type: () => MediaDto, isArray: true }) photos: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + baselineReportUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + localGovernanceOrderLetterUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + eventsMeetingsPhotos: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + localGovernanceProofOfPartnershipUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + topThreeSuccessesUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + directJobsUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + convergenceJobsUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + convergenceSchemesUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + livelihoodActivitiesUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + directLivelihoodImpactsUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + certifiedDatabaseUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + physicalAssetsPhotos: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + indirectCommunityPartnersUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + trainingCapacityBuildingUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + trainingCapacityBuildingPhotos: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + financialReportUpload: MediaDto[]; } diff --git a/apps/entity-service/src/entities/dto/project.dto.ts b/apps/entity-service/src/entities/dto/project.dto.ts index e1faa25f..2b795c8f 100644 --- a/apps/entity-service/src/entities/dto/project.dto.ts +++ b/apps/entity-service/src/entities/dto/project.dto.ts @@ -37,11 +37,10 @@ export class ProjectLightDto extends EntityDto { organisationName: string | null; @ApiProperty({ - nullable: true, description: "Entity status for this project", enum: ENTITY_STATUSES }) - status: EntityStatus | null; + status: EntityStatus; @ApiProperty({ nullable: true, diff --git a/apps/entity-service/src/entities/dto/site-report.dto.ts b/apps/entity-service/src/entities/dto/site-report.dto.ts index 88ef72f8..aa7c208b 100644 --- a/apps/entity-service/src/entities/dto/site-report.dto.ts +++ b/apps/entity-service/src/entities/dto/site-report.dto.ts @@ -87,6 +87,9 @@ export class SiteReportLightDto extends EntityDto { @ApiProperty() createdAt: Date; + + @ApiProperty({ nullable: true }) + nothingToReport: boolean | null; } export type AdditionalSiteReportLightProps = Pick; @@ -152,9 +155,6 @@ export class SiteReportFullDto extends SiteReportLightDto { @ApiProperty({ nullable: true }) feedbackFields: string[] | null; - @ApiProperty() - nothingToReport: boolean; - @ApiProperty({ nullable: true }) completion: number | null; @@ -292,4 +292,16 @@ export class SiteReportFullDto extends SiteReportLightDto { @ApiProperty({ type: () => MediaDto, isArray: true }) documentFiles: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + treePlantingUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + anrPhotos: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + soilWaterConservationUpload: MediaDto[]; + + @ApiProperty({ type: () => MediaDto, isArray: true }) + soilWaterConservationPhotos: MediaDto[]; } diff --git a/apps/entity-service/src/entities/dto/site.dto.ts b/apps/entity-service/src/entities/dto/site.dto.ts index 8645ac65..978155fa 100644 --- a/apps/entity-service/src/entities/dto/site.dto.ts +++ b/apps/entity-service/src/entities/dto/site.dto.ts @@ -56,6 +56,12 @@ export class SiteLightDto extends EntityDto { @ApiProperty() treesPlantedCount: number; + @ApiProperty({ nullable: true }) + hectaresToRestoreGoal: number; + + @ApiProperty() + totalHectaresRestoredSum: number; + @ApiProperty() createdAt: Date; @@ -63,7 +69,7 @@ export class SiteLightDto extends EntityDto { updatedAt: Date; } -export type AdditionalSiteLightProps = Pick; +export type AdditionalSiteLightProps = Pick; export type AdditionalSiteFullProps = AdditionalSiteLightProps & AdditionalProps>; export type SiteMedia = Pick; diff --git a/apps/entity-service/src/entities/dto/strata.dto.ts b/apps/entity-service/src/entities/dto/strata.dto.ts new file mode 100644 index 00000000..a78121d7 --- /dev/null +++ b/apps/entity-service/src/entities/dto/strata.dto.ts @@ -0,0 +1,24 @@ +import { JsonApiDto } from "@terramatch-microservices/common/decorators"; +import { pickApiProperties } from "@terramatch-microservices/common/dto/json-api-attributes"; +import { AssociationDto, AssociationDtoAdditionalProps } from "./association.dto"; +import { Strata } from "@terramatch-microservices/database/entities/stratas.entity"; +import { AllowNull, Column } from "sequelize-typescript"; +import { INTEGER, STRING } from "sequelize"; + +@JsonApiDto({ type: "stratas" }) +export class StrataDto extends AssociationDto { + constructor(strata: Strata, additional: AssociationDtoAdditionalProps) { + super({ + ...pickApiProperties(strata, StrataDto), + ...additional + }); + } + + @AllowNull + @Column(STRING) + description: string | null; + + @AllowNull + @Column(INTEGER) + extent: string | null; +} diff --git a/apps/entity-service/src/entities/entities.controller.spec.ts b/apps/entity-service/src/entities/entities.controller.spec.ts index 10c60734..989c69e8 100644 --- a/apps/entity-service/src/entities/entities.controller.spec.ts +++ b/apps/entity-service/src/entities/entities.controller.spec.ts @@ -7,11 +7,12 @@ import { AdditionalProjectFullProps, ProjectFullDto, ProjectLightDto } from "./d import { Project } from "@terramatch-microservices/database/entities"; import { PolicyService } from "@terramatch-microservices/common"; import { ProjectFactory } from "@terramatch-microservices/database/factories"; -import { NotFoundException, UnauthorizedException } from "@nestjs/common"; +import { BadRequestException, NotFoundException, UnauthorizedException } from "@nestjs/common"; import { EntityQueryDto } from "./dto/entity-query.dto"; import { faker } from "@faker-js/faker"; +import { EntityUpdateData } from "./dto/entity-update.dto"; -class StubProcessor extends EntityProcessor { +class StubProcessor extends EntityProcessor { LIGHT_DTO = ProjectLightDto; FULL_DTO = ProjectFullDto; @@ -22,6 +23,7 @@ class StubProcessor extends EntityProcessor Promise.resolve({ id: faker.string.uuid(), dto: new ProjectLightDto() })); delete = jest.fn(() => Promise.resolve()); + update = jest.fn(() => Promise.resolve()); } describe("EntitiesController", () => { @@ -116,4 +118,64 @@ describe("EntitiesController", () => { expect(result.data).toBeUndefined(); }); }); + + describe("entityUpdate", () => { + it("should throw if the entity payload type does not match the path type", async () => { + await expect( + controller.entityUpdate( + { entity: "sites", uuid: "asdf" }, + { data: { type: "projects", id: "asdf", attributes: {} } } + ) + ).rejects.toThrow(BadRequestException); + }); + + it("should throw if the entity payload id does not match the path uuid", async () => { + await expect( + controller.entityUpdate( + { entity: "projects", uuid: "asdf" }, + { data: { type: "projects", id: "qwerty", attributes: {} } } + ) + ).rejects.toThrow(BadRequestException); + }); + + it("should throw if the model is not found", async () => { + jest.spyOn(processor, "findOne").mockResolvedValue(null); + await expect( + controller.entityUpdate( + { entity: "projects", uuid: "asdf" }, + { data: { type: "projects", id: "asdf", attributes: {} } } + ) + ).rejects.toThrow(NotFoundException); + }); + + it("authorizes access to the model", async () => { + const project = await ProjectFactory.create(); + processor.findOne.mockResolvedValue(project); + const { id, uuid } = project; + policyService.authorize.mockRejectedValueOnce(new UnauthorizedException()); + await expect( + controller.entityUpdate({ entity: "projects", uuid }, { data: { type: "projects", id: uuid, attributes: {} } }) + ).rejects.toThrow(UnauthorizedException); + + policyService.authorize.mockReset(); + policyService.authorize.mockResolvedValueOnce(undefined); + await controller.entityUpdate( + { entity: "projects", uuid }, + { data: { type: "projects", id: uuid, attributes: {} } } + ); + expect(policyService.authorize).toHaveBeenCalledWith("update", expect.objectContaining({ id, uuid })); + }); + + it("calls update on the processor and creates the DTO", async () => { + const project = await ProjectFactory.create(); + processor.findOne.mockResolvedValue(project); + const { uuid } = project; + policyService.authorize.mockResolvedValueOnce(undefined); + const attributes = { status: "approved", feedback: "foo" }; + await controller.entityUpdate({ entity: "projects", uuid }, { data: { type: "projects", id: uuid, attributes } }); + + expect(processor.update).toHaveBeenCalledWith(project, attributes); + expect(processor.getFullDto).toHaveBeenCalledWith(project); + }); + }); }); diff --git a/apps/entity-service/src/entities/entities.controller.ts b/apps/entity-service/src/entities/entities.controller.ts index 67526248..169e3cfd 100644 --- a/apps/entity-service/src/entities/entities.controller.ts +++ b/apps/entity-service/src/entities/entities.controller.ts @@ -1,10 +1,12 @@ import { BadRequestException, + Body, Controller, Delete, Get, NotFoundException, Param, + Patch, Query, UnauthorizedException } from "@nestjs/common"; @@ -24,10 +26,12 @@ import { NurseryFullDto, NurseryLightDto } from "./dto/nursery.dto"; import { EntityModel } from "@terramatch-microservices/database/constants/entities"; import { JsonApiDeletedResponse } from "@terramatch-microservices/common/decorators/json-api-response.decorator"; import { NurseryReportFullDto, NurseryReportLightDto } from "./dto/nursery-report.dto"; -import { SiteReportLightDto, SiteReportFullDto } from "./dto/site-report.dto"; +import { SiteReportFullDto, SiteReportLightDto } from "./dto/site-report.dto"; +import { EntityUpdateBody } from "./dto/entity-update.dto"; +import { SupportedEntities } from "./dto/entity.dto"; @Controller("entities/v3") -@ApiExtraModels(ANRDto, ProjectApplicationDto, MediaDto, EntitySideload) +@ApiExtraModels(ANRDto, ProjectApplicationDto, MediaDto, EntitySideload, SupportedEntities) export class EntitiesController { constructor(private readonly policyService: PolicyService, private readonly entitiesService: EntitiesService) {} @@ -108,4 +112,42 @@ export class EntitiesController { return buildDeletedResponse(getDtoType(processor.FULL_DTO), model.uuid); } + + @Patch(":entity/:uuid") + @ApiOperation({ + operationId: "entityUpdate", + summary: "Update various supported entity fields directly. Typically used for status transitions" + }) + @ExceptionResponse(UnauthorizedException, { + description: "Authentication failed, or resource unavailable to current user." + }) + @ExceptionResponse(NotFoundException, { description: "Resource not found." }) + @ExceptionResponse(BadRequestException, { description: "Request params are malformed." }) + async entityUpdate( + @Param() { entity, uuid }: SpecificEntityDto, + @Body() updatePayload: EntityUpdateBody + ) { + // The structure of the EntityUpdateBody ensures that the `type` field in the body controls + // which update body is used for validation, but it doesn't make sure that the body of the + // request matches the type in the URL path. + if (entity !== updatePayload.data.type) { + throw new BadRequestException("Entity type in path and payload do not match"); + } + if (uuid !== updatePayload.data.id) { + throw new BadRequestException("Entity id in path and payload do not match"); + } + + const processor = this.entitiesService.createEntityProcessor(entity); + const model = await processor.findOne(uuid); + if (model == null) throw new NotFoundException(); + + await this.policyService.authorize("update", model); + + await processor.update(model, updatePayload.data.attributes); + + const document = buildJsonApi(processor.FULL_DTO); + const { id, dto } = await processor.getFullDto(model); + document.addData(id, dto); + return document.serialize(); + } } diff --git a/apps/entity-service/src/entities/entities.service.ts b/apps/entity-service/src/entities/entities.service.ts index 6096acac..246e2ac9 100644 --- a/apps/entity-service/src/entities/entities.service.ts +++ b/apps/entity-service/src/entities/entities.service.ts @@ -5,7 +5,14 @@ import { EntityProcessor } from "./processors/entity-processor"; import { EntityQueryDto } from "./dto/entity-query.dto"; import { PaginatedQueryBuilder } from "@terramatch-microservices/database/util/paginated-query.builder"; import { MediaService } from "@terramatch-microservices/common/media/media.service"; -import { Demographic, Media, Seeding, TreeSpecies, User } from "@terramatch-microservices/database/entities"; +import { + Demographic, + Disturbance, + Media, + Seeding, + TreeSpecies, + User +} from "@terramatch-microservices/database/entities"; import { MediaDto } from "./dto/media.dto"; import { MediaCollection } from "@terramatch-microservices/database/types/media"; import { groupBy } from "lodash"; @@ -23,18 +30,24 @@ import { SeedingDto } from "./dto/seeding.dto"; import { TreeSpeciesDto } from "./dto/tree-species.dto"; import { DemographicDto } from "./dto/demographic.dto"; import { PolicyService } from "@terramatch-microservices/common"; +import { EntityUpdateData } from "./dto/entity-update.dto"; import { LocalizationService } from "@terramatch-microservices/common/localization/localization.service"; import { ITranslateParams } from "@transifex/native"; +import { Invasive } from "@terramatch-microservices/database/entities/invasive.entity"; +import { DisturbanceDto } from "./dto/disturbance.dto"; +import { InvasiveDto } from "./dto/invasive.dto"; +import { Strata } from "@terramatch-microservices/database/entities/stratas.entity"; +import { StrataDto } from "./dto/strata.dto"; // The keys of this array must match the type in the resulting DTO. -const ENTITY_PROCESSORS = { +export const ENTITY_PROCESSORS = { projects: ProjectProcessor, sites: SiteProcessor, nurseries: NurseryProcessor, projectReports: ProjectReportProcessor, nurseryReports: NurseryReportProcessor, siteReports: SiteReportProcessor -}; +} as const; export type ProcessableEntity = keyof typeof ENTITY_PROCESSORS; export const PROCESSABLE_ENTITIES = Object.keys(ENTITY_PROCESSORS) as ProcessableEntity[]; @@ -45,7 +58,9 @@ export const POLYGON_STATUSES_FILTERS = [ "needs-more-information", "draft" ] as const; + export type PolygonStatusFilter = (typeof POLYGON_STATUSES_FILTERS)[number]; + const ASSOCIATION_PROCESSORS = { demographics: AssociationProcessor.buildSimpleProcessor( DemographicDto, @@ -65,6 +80,17 @@ const ASSOCIATION_PROCESSORS = { attributes: ["uuid", "name", "taxonId", "collection", [fn("SUM", col("amount")), "amount"]], group: ["taxonId", "name", "collection"] }) + ), + disturbances: AssociationProcessor.buildSimpleProcessor( + DisturbanceDto, + ({ id: disturbanceableId }, disturbanceableType) => + Disturbance.findAll({ where: { disturbanceableType, disturbanceableId, hidden: false } }) + ), + invasives: AssociationProcessor.buildSimpleProcessor(InvasiveDto, ({ id: invasiveableId }, invasiveableType) => + Invasive.findAll({ where: { invasiveableType, invasiveableId, hidden: false } }) + ), + stratas: AssociationProcessor.buildSimpleProcessor(StrataDto, ({ id: stratasableId }, stratasableType) => + Strata.findAll({ where: { stratasableType, stratasableId, hidden: false } }) ) }; @@ -93,6 +119,10 @@ export class EntitiesService { await this.policyService.authorize(action, subject); } + async isFrameworkAdmin({ frameworkKey }: T) { + return (await this.getPermissions()).includes(`framework-${frameworkKey}`); + } + private _userLocale?: string; async getUserLocale() { if (this._userLocale == null) { @@ -111,10 +141,10 @@ export class EntitiesService { throw new BadRequestException(`Entity type invalid: ${entity}`); } - return new processorClass(this, entity) as unknown as EntityProcessor; + return new processorClass(this, entity) as unknown as EntityProcessor; } - createAssociationProcessor, D extends AssociationDto>( + createAssociationProcessor>( entityType: EntityType, uuid: string, association: ProcessableAssociation diff --git a/apps/entity-service/src/entities/entity-associations.controller.spec.ts b/apps/entity-service/src/entities/entity-associations.controller.spec.ts index 89665613..5db280af 100644 --- a/apps/entity-service/src/entities/entity-associations.controller.spec.ts +++ b/apps/entity-service/src/entities/entity-associations.controller.spec.ts @@ -35,7 +35,6 @@ describe("EntityAssociationsController", () => { }).compile(); controller = module.get(EntityAssociationsController); - // @ts-expect-error union type complexity entitiesService.createAssociationProcessor.mockImplementation((entity, uuid) => { return new StubProcessor(entity, uuid, ProjectReport); }); @@ -50,7 +49,6 @@ describe("EntityAssociationsController", () => { policyService.getPermissions.mockResolvedValue(["view-dashboard"]); const pr = await ProjectReportFactory.create(); const processor = new StubProcessor("projectReports", pr.uuid, ProjectReport); - // @ts-expect-error union type complexity entitiesService.createAssociationProcessor.mockImplementation(() => processor); const spy = jest.spyOn(processor, "getBaseEntity"); await controller.associationIndex({ diff --git a/apps/entity-service/src/entities/entity-associations.controller.ts b/apps/entity-service/src/entities/entity-associations.controller.ts index 3d541c3e..b9ab7694 100644 --- a/apps/entity-service/src/entities/entity-associations.controller.ts +++ b/apps/entity-service/src/entities/entity-associations.controller.ts @@ -8,6 +8,9 @@ import { DemographicCollections, DemographicDto, DemographicEntryDto } from "./d import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/common/decorators"; import { SeedingDto } from "./dto/seeding.dto"; import { TreeSpeciesDto } from "./dto/tree-species.dto"; +import { DisturbanceDto } from "./dto/disturbance.dto"; +import { InvasiveDto } from "./dto/invasive.dto"; +import { StrataDto } from "./dto/strata.dto"; @Controller("entities/v3/:entity/:uuid") @ApiExtraModels(DemographicEntryDto, DemographicCollections) @@ -22,7 +25,10 @@ export class EntityAssociationsController { @JsonApiResponse([ { data: DemographicDto, hasMany: true }, { data: SeedingDto, hasMany: true }, - { data: TreeSpeciesDto, hasMany: true } + { data: TreeSpeciesDto, hasMany: true }, + { data: DisturbanceDto, hasMany: true }, + { data: InvasiveDto, hasMany: true }, + { data: StrataDto, hasMany: true } ]) @ExceptionResponse(BadRequestException, { description: "Param types invalid" }) @ExceptionResponse(NotFoundException, { description: "Base entity not found" }) diff --git a/apps/entity-service/src/entities/processors/association-processor.ts b/apps/entity-service/src/entities/processors/association-processor.ts index 1369a730..2dd79761 100644 --- a/apps/entity-service/src/entities/processors/association-processor.ts +++ b/apps/entity-service/src/entities/processors/association-processor.ts @@ -5,7 +5,7 @@ import { EntityClass, EntityModel, EntityType } from "@terramatch-microservices/ import { intersection } from "lodash"; import { UuidModel } from "@terramatch-microservices/database/types/util"; -export abstract class AssociationProcessor, D extends AssociationDto> { +export abstract class AssociationProcessor> { abstract readonly DTO: Type; constructor( @@ -19,7 +19,7 @@ export abstract class AssociationProcessor, D extends Ass * are simple enough that providing a reference to the DTO class, and a getter of associations based on the * base entity is enough. */ - static buildSimpleProcessor, D extends AssociationDto>( + static buildSimpleProcessor>( dtoClass: Type, associationGetter: (entity: EntityModel, entityLaravelType: string) => Promise ) { @@ -53,14 +53,18 @@ export abstract class AssociationProcessor, D extends Ass return this._baseEntity; } - async addDtos(document: DocumentBuilder): Promise { + async addDtos(document: DocumentBuilder, asIncluded = false): Promise { const associations = await this.getAssociations(await this.getBaseEntity()); const additionalProps = { entityType: this.entityType, entityUuid: this.entityUuid }; const indexIds: string[] = []; for (const association of associations) { indexIds.push(association.uuid); - document.addData(association.uuid, new this.DTO(association, additionalProps)); + if (asIncluded) { + document.addIncluded(association.uuid, new this.DTO(association, additionalProps)); + } else { + document.addData(association.uuid, new this.DTO(association, additionalProps)); + } } const resource = getDtoType(this.DTO); diff --git a/apps/entity-service/src/entities/processors/entity-processor.ts b/apps/entity-service/src/entities/processors/entity-processor.ts index e6393847..88d66ea4 100644 --- a/apps/entity-service/src/entities/processors/entity-processor.ts +++ b/apps/entity-service/src/entities/processors/entity-processor.ts @@ -2,11 +2,18 @@ import { Model, ModelCtor } from "sequelize-typescript"; import { Attributes, col, fn, WhereOptions } from "sequelize"; import { DocumentBuilder, getStableRequestQuery, IndexData } from "@terramatch-microservices/common/util"; import { EntitiesService, ProcessableEntity } from "../entities.service"; -import { EntityQueryDto } from "../dto/entity-query.dto"; +import { EntityQueryDto, SideloadType } from "../dto/entity-query.dto"; import { BadRequestException, Type } from "@nestjs/common"; import { EntityDto } from "../dto/entity.dto"; -import { EntityClass, EntityModel } from "@terramatch-microservices/database/constants/entities"; +import { EntityModel, ReportModel } from "@terramatch-microservices/database/constants/entities"; import { Action } from "@terramatch-microservices/database/entities/action.entity"; +import { EntityUpdateData, ReportUpdateAttributes } from "../dto/entity-update.dto"; +import { + APPROVED, + NEEDS_MORE_INFORMATION, + RESTORATION_IN_PROGRESS +} from "@terramatch-microservices/database/constants/status"; +import { ProjectReport } from "@terramatch-microservices/database/entities"; export type Aggregate> = { func: string; @@ -50,11 +57,14 @@ const getIndexData = ( export abstract class EntityProcessor< ModelType extends EntityModel, LightDto extends EntityDto, - FullDto extends EntityDto + FullDto extends EntityDto, + UpdateDto extends EntityUpdateData > { abstract readonly LIGHT_DTO: Type; abstract readonly FULL_DTO: Type; + readonly APPROVAL_STATUSES = [APPROVED, NEEDS_MORE_INFORMATION, RESTORATION_IN_PROGRESS]; + constructor(protected readonly entitiesService: EntitiesService, protected readonly resource: ProcessableEntity) {} abstract findOne(uuid: string): Promise; @@ -63,11 +73,27 @@ export abstract class EntityProcessor< abstract getFullDto(model: ModelType): Promise>; abstract getLightDto(model: ModelType): Promise>; + async getFullDtos(models: ModelType[]): Promise[]> { + const results: DtoResult[] = []; + for (const model of models) { + results.push(await this.getFullDto(model)); + } + return results; + } + + async getLightDtos(models: ModelType[]): Promise[]> { + const results: DtoResult[] = []; + for (const model of models) { + results.push(await this.getLightDto(model)); + } + return results; + } + /* eslint-disable @typescript-eslint/no-unused-vars */ async processSideload( document: DocumentBuilder, model: ModelType, - entity: ProcessableEntity, + entity: SideloadType, pageSize: number ): Promise { throw new BadRequestException("This entity does not support sideloading"); @@ -82,8 +108,9 @@ export abstract class EntityProcessor< if (models.length !== 0) { await this.entitiesService.authorize("read", models); - for (const model of models) { - const { id, dto } = await this.getLightDto(model); + const dtoResults = await this.getLightDtos(models); + + for (const { id, dto } of dtoResults) { indexIds.push(id); if (sideloaded) document.addIncluded(id, dto); else document.addData(id, dto); @@ -102,7 +129,67 @@ export abstract class EntityProcessor< } async delete(model: ModelType) { - await Action.targetable((model.constructor as EntityClass).LARAVEL_TYPE, model.id).destroy(); + await Action.for(model).destroy(); await model.destroy(); } + + /** + * Performs the basic function of setting fields in EntityUpdateAttributes and saving the model. + * If this concrete processor needs to support more fields on the update dto, override this method + * and set the appropriate fields and then call super.update() + */ + async update(model: ModelType, update: UpdateDto) { + if (update.status != null) { + if (this.APPROVAL_STATUSES.includes(update.status)) { + await this.entitiesService.authorize("approve", model); + + // If an admin is doing an update, set the feedback / feedbackFields to whatever is in the + // request, even if it's null. We ignore feedback / feedbackFields if the status is not + // also being updated. + model.feedback = update.feedback; + model.feedbackFields = update.feedbackFields; + } + + model.status = update.status as ModelType["status"]; + } + + await model.save(); + } +} + +export abstract class ReportProcessor< + ModelType extends ReportModel, + LightDto extends EntityDto, + FullDto extends EntityDto, + UpdateDto extends ReportUpdateAttributes +> extends EntityProcessor { + async update(model: ModelType, update: UpdateDto) { + if (update.nothingToReport != null) { + if (model instanceof ProjectReport) { + throw new BadRequestException("ProjectReport does not support nothingToReport"); + } + + if (update.nothingToReport !== model.nothingToReport) { + model.nothingToReport = update.nothingToReport; + + if (model.nothingToReport) { + const statusChanged = update.status != null && update.status !== model.status; + if (statusChanged && update.status !== "awaiting-approval") { + throw new BadRequestException( + "Cannot set status to anything other than 'awaiting-approval' with nothingToReport: true" + ); + } + + if (model.submittedAt == null) { + model.completion = 100; + model.submittedAt = new Date(); + } + + model.status = "awaiting-approval"; + } + } + } + + await super.update(model, update); + } } diff --git a/apps/entity-service/src/entities/processors/entity.processor.spec.ts b/apps/entity-service/src/entities/processors/entity.processor.spec.ts index 5319d748..50a29ccd 100644 --- a/apps/entity-service/src/entities/processors/entity.processor.spec.ts +++ b/apps/entity-service/src/entities/processors/entity.processor.spec.ts @@ -1,27 +1,35 @@ -import { ProjectProcessor } from "./project.processor"; import { Test } from "@nestjs/testing"; import { MediaService } from "@terramatch-microservices/common/media/media.service"; -import { createMock } from "@golevelup/ts-jest"; -import { EntitiesService } from "../entities.service"; -import { ProjectFactory } from "@terramatch-microservices/database/factories"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { EntitiesService, ProcessableEntity } from "../entities.service"; +import { + NurseryReportFactory, + ProjectFactory, + ProjectReportFactory, + SiteReportFactory +} from "@terramatch-microservices/database/factories"; import { ActionFactory } from "@terramatch-microservices/database/factories/action.factory"; import { PolicyService } from "@terramatch-microservices/common"; import { LocalizationService } from "@terramatch-microservices/common/localization/localization.service"; +import { BadRequestException, UnauthorizedException } from "@nestjs/common"; describe("EntityProcessor", () => { - let processor: ProjectProcessor; + let service: EntitiesService; + let policyService: DeepMocked; + + const createProcessor = (entity: ProcessableEntity = "projects") => service.createEntityProcessor(entity); beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ { provide: MediaService, useValue: createMock() }, - { provide: PolicyService, useValue: createMock() }, + { provide: PolicyService, useValue: (policyService = createMock()) }, { provide: LocalizationService, useValue: createMock() }, EntitiesService ] }).compile(); - processor = module.get(EntitiesService).createEntityProcessor("projects") as ProjectProcessor; + service = module.get(EntitiesService); }); afterEach(async () => { @@ -31,7 +39,7 @@ describe("EntityProcessor", () => { describe("delete", () => { it("deletes the requested model", async () => { const project = await ProjectFactory.create(); - await processor.delete(project); + await createProcessor().delete(project); await project.reload({ paranoid: false }); expect(project.deletedAt).not.toBeNull(); }); @@ -39,11 +47,85 @@ describe("EntityProcessor", () => { it("deletes associated actions", async () => { const project = await ProjectFactory.create(); const actions = await ActionFactory.forProject.createMany(2, { targetableId: project.id }); - await processor.delete(project); + await createProcessor().delete(project); for (const action of actions) { await action.reload({ paranoid: false }); expect(action.deletedAt).not.toBeNull(); } }); }); + + describe("update", () => { + it("calls model.save", async () => { + const project = await ProjectFactory.create(); + const spy = jest.spyOn(project, "save"); + await createProcessor().update(project, {}); + expect(spy).toHaveBeenCalled(); + }); + + it("authorizes for approval when appropriate", async () => { + const project = await ProjectFactory.create({ status: "started", feedback: null, feedbackFields: null }); + + policyService.authorize.mockResolvedValueOnce(undefined); + const processor = createProcessor(); + await processor.update(project, { + status: "awaiting-approval", + feedback: "foo", + feedbackFields: ["bar"] + }); + expect(policyService.authorize).not.toHaveBeenCalled(); + // These two should be ignored for non approval statuses + expect(project.feedback).toBeNull(); + expect(project.feedbackFields).toBeNull(); + policyService.authorize.mockReset(); + + policyService.authorize.mockRejectedValueOnce(new UnauthorizedException()); + await expect(processor.update(project, { status: "approved" })).rejects.toThrow(UnauthorizedException); + + policyService.authorize.mockResolvedValueOnce(undefined); + await processor.update(project, { status: "approved", feedback: "foo", feedbackFields: ["bar"] }); + expect(project.status).toEqual("approved"); + expect(project.feedback).toEqual("foo"); + expect(project.feedbackFields).toEqual(["bar"]); + }); + + describe("nothingToReport", () => { + it("throws when the property is present on a project report update", async () => { + const projectReport = await ProjectReportFactory.create(); + const processor = createProcessor("projectReports"); + await expect(processor.update(projectReport, { nothingToReport: true })).rejects.toThrow(BadRequestException); + await expect(processor.update(projectReport, { nothingToReport: false })).rejects.toThrow(BadRequestException); + }); + + it("throws if the request attempts to set status", async () => { + let siteReport = await SiteReportFactory.create({ submittedAt: null }); + const processor = createProcessor("siteReports"); + await expect(processor.update(siteReport, { nothingToReport: true, status: "started" })).rejects.toThrow( + BadRequestException + ); + siteReport = await SiteReportFactory.create({ submittedAt: null }); + await expect( + processor.update(siteReport, { nothingToReport: false, status: "started" }) + ).resolves.not.toThrow(); + siteReport = await SiteReportFactory.create({ submittedAt: null }); + await expect( + processor.update(siteReport, { nothingToReport: true, status: "awaiting-approval" }) + ).resolves.not.toThrow(); + }); + + it("Sets completion and submission date", async () => { + const report = await NurseryReportFactory.create({ + completion: null, + submittedAt: null, + nothingToReport: null + }); + const processor = createProcessor("nurseryReports"); + await processor.update(report, { nothingToReport: true }); + expect(report.nothingToReport).toBe(true); + expect(report.status).toBe("awaiting-approval"); + expect(report.completion).toBe(100); + expect(report.submittedAt).not.toBeNull(); + }); + }); + }); }); diff --git a/apps/entity-service/src/entities/processors/nursery-report.processor.spec.ts b/apps/entity-service/src/entities/processors/nursery-report.processor.spec.ts index f7f69fa7..3399e033 100644 --- a/apps/entity-service/src/entities/processors/nursery-report.processor.spec.ts +++ b/apps/entity-service/src/entities/processors/nursery-report.processor.spec.ts @@ -60,7 +60,7 @@ describe("NurseryReportProcessor", () => { }: { permissions?: string[]; sortField?: string; sortUp?: boolean; total?: number } = {} ) { policyService.getPermissions.mockResolvedValue(permissions); - const { models, paginationTotal } = await processor.findMany(query as EntityQueryDto, userId); + const { models, paginationTotal } = await processor.findMany(query as EntityQueryDto); expect(models.length).toBe(expected.length); expect(paginationTotal).toBe(total); diff --git a/apps/entity-service/src/entities/processors/nursery-report.processor.ts b/apps/entity-service/src/entities/processors/nursery-report.processor.ts index 3987e12a..9a8b3501 100644 --- a/apps/entity-service/src/entities/processors/nursery-report.processor.ts +++ b/apps/entity-service/src/entities/processors/nursery-report.processor.ts @@ -1,5 +1,5 @@ import { Media, Nursery, NurseryReport, ProjectReport, ProjectUser } from "@terramatch-microservices/database/entities"; -import { EntityProcessor } from "./entity-processor"; +import { ReportProcessor } from "./entity-processor"; import { EntityQueryDto } from "../dto/entity-query.dto"; import { Includeable, Op } from "sequelize"; import { BadRequestException } from "@nestjs/common"; @@ -10,11 +10,13 @@ import { NurseryReportLightDto, NurseryReportMedia } from "../dto/nursery-report.dto"; +import { ReportUpdateAttributes } from "../dto/entity-update.dto"; -export class NurseryReportProcessor extends EntityProcessor< +export class NurseryReportProcessor extends ReportProcessor< NurseryReport, NurseryReportLightDto, - NurseryReportFullDto + NurseryReportFullDto, + ReportUpdateAttributes > { readonly LIGHT_DTO = NurseryReportLightDto; readonly FULL_DTO = NurseryReportFullDto; @@ -50,7 +52,7 @@ export class NurseryReportProcessor extends EntityProcessor< }); } - async findMany(query: EntityQueryDto, userId?: number) { + async findMany(query: EntityQueryDto) { const nurseryAssociation: Includeable = { association: "nursery", attributes: ["id", "uuid", "name"], @@ -83,9 +85,13 @@ export class NurseryReportProcessor extends EntityProcessor< if (frameworkPermissions?.length > 0) { builder.where({ frameworkKey: { [Op.in]: frameworkPermissions } }); } else if (permissions?.includes("manage-own")) { - builder.where({ "$nursery.project.id$": { [Op.in]: ProjectUser.userProjectsSubquery(userId) } }); + builder.where({ + "$nursery.project.id$": { [Op.in]: ProjectUser.userProjectsSubquery(this.entitiesService.userId) } + }); } else if (permissions?.includes("projects-manage")) { - builder.where({ "$nursery.project.id$": { [Op.in]: ProjectUser.projectsManageSubquery(userId) } }); + builder.where({ + "$nursery.project.id$": { [Op.in]: ProjectUser.projectsManageSubquery(this.entitiesService.userId) } + }); } const associationFieldMap = { @@ -133,8 +139,7 @@ export class NurseryReportProcessor extends EntityProcessor< } async getFullDto(nurseryReport: NurseryReport) { - const nurseryReportId = nurseryReport.id; - const mediaCollection = await Media.nurseryReport(nurseryReportId).findAll(); + const mediaCollection = await Media.for(nurseryReport).findAll(); const reportTitle = await this.getReportTitle(nurseryReport); const projectReportTitle = await this.getProjectReportTitle(nurseryReport); const props: AdditionalNurseryReportFullProps = { diff --git a/apps/entity-service/src/entities/processors/nursery.processor.ts b/apps/entity-service/src/entities/processors/nursery.processor.ts index 6c22d56b..1926ef59 100644 --- a/apps/entity-service/src/entities/processors/nursery.processor.ts +++ b/apps/entity-service/src/entities/processors/nursery.processor.ts @@ -1,19 +1,18 @@ -import { - Action, - Media, - Nursery, - NurseryReport, - Project, - ProjectUser -} from "@terramatch-microservices/database/entities"; +import { Media, Nursery, NurseryReport, Project, ProjectUser } from "@terramatch-microservices/database/entities"; import { AdditionalNurseryFullProps, NurseryFullDto, NurseryLightDto, NurseryMedia } from "../dto/nursery.dto"; import { EntityProcessor } from "./entity-processor"; import { EntityQueryDto } from "../dto/entity-query.dto"; import { col, fn, Includeable, Op } from "sequelize"; import { BadRequestException, NotAcceptableException } from "@nestjs/common"; import { FrameworkKey } from "@terramatch-microservices/database/constants/framework"; +import { EntityUpdateAttributes } from "../dto/entity-update.dto"; -export class NurseryProcessor extends EntityProcessor { +export class NurseryProcessor extends EntityProcessor< + Nursery, + NurseryLightDto, + NurseryFullDto, + EntityUpdateAttributes +> { readonly LIGHT_DTO = NurseryLightDto; readonly FULL_DTO = NurseryFullDto; @@ -114,10 +113,7 @@ export class NurseryProcessor extends EntityProcessor { let processor: ProjectReportProcessor; @@ -432,4 +435,22 @@ describe("ProjectReportProcessor", () => { }); }); }); + + describe("processSideload", () => { + it("should include sideloaded demographics", async () => { + const projectReport = await ProjectReportFactory.create(); + await DemographicFactory.forProjectReportWorkday.create({ demographicalId: projectReport.id }); + await DemographicFactory.forProjectReportJobs.create({ demographicalId: projectReport.id }); + + policyService.getPermissions.mockResolvedValue(["projects-read"]); + const document = buildJsonApi(ProjectReportLightDto); + await processor.addIndex(document, { + sideloads: [{ entity: "demographics", pageSize: 5 }] + }); + + const result = document.serialize(); + expect(result.included?.length).toBe(2); + expect(result.included.filter(({ type }) => type === "demographics").length).toBe(2); + }); + }); }); diff --git a/apps/entity-service/src/entities/processors/project-report.processor.ts b/apps/entity-service/src/entities/processors/project-report.processor.ts index 5fe3d59b..420499b1 100644 --- a/apps/entity-service/src/entities/processors/project-report.processor.ts +++ b/apps/entity-service/src/entities/processors/project-report.processor.ts @@ -1,12 +1,12 @@ import { ProjectReport } from "@terramatch-microservices/database/entities/project-report.entity"; -import { EntityProcessor } from "./entity-processor"; +import { ReportProcessor } from "./entity-processor"; import { AdditionalProjectReportFullProps, ProjectReportFullDto, ProjectReportLightDto, ProjectReportMedia } from "../dto/project-report.dto"; -import { EntityQueryDto } from "../dto/entity-query.dto"; +import { EntityQueryDto, SideloadType } from "../dto/entity-query.dto"; import { Includeable, Op } from "sequelize"; import { BadRequestException } from "@nestjs/common"; import { FrameworkKey } from "@terramatch-microservices/database/constants/framework"; @@ -20,11 +20,17 @@ import { TreeSpecies } from "@terramatch-microservices/database/entities"; import { sumBy } from "lodash"; +import { ProcessableAssociation } from "../entities.service"; +import { DocumentBuilder } from "@terramatch-microservices/common/util"; -export class ProjectReportProcessor extends EntityProcessor< +const SUPPORTED_ASSOCIATIONS: ProcessableAssociation[] = ["demographics", "seedings", "treeSpecies"]; +import { ReportUpdateAttributes } from "../dto/entity-update.dto"; + +export class ProjectReportProcessor extends ReportProcessor< ProjectReport, ProjectReportLightDto, - ProjectReportFullDto + ProjectReportFullDto, + ReportUpdateAttributes > { readonly LIGHT_DTO = ProjectReportLightDto; readonly FULL_DTO = ProjectReportFullDto; @@ -126,8 +132,20 @@ export class ProjectReportProcessor extends EntityProcessor< return { models: await builder.execute(), paginationTotal: await builder.paginationTotal() }; } + async processSideload(document: DocumentBuilder, model: ProjectReport, entity: SideloadType): Promise { + if (SUPPORTED_ASSOCIATIONS.includes(entity as ProcessableAssociation)) { + const processor = this.entitiesService.createAssociationProcessor( + "projectReports", + model.uuid, + entity as ProcessableAssociation + ); + await processor.addDtos(document, true); + } else { + throw new BadRequestException(`Project reports only support sideloading: ${SUPPORTED_ASSOCIATIONS.join(", ")}`); + } + } + async getFullDto(projectReport: ProjectReport) { - const projectReportId = projectReport.id; const taskId = projectReport.taskId; const reportTitle = await this.getReportTitle(projectReport); const siteReportsIdsTask = ProjectReport.siteReportIdsTaskSubquery([taskId]); @@ -157,7 +175,7 @@ export class ProjectReportProcessor extends EntityProcessor< readableCompletionStatus, createdByUser, ...(this.entitiesService.mapMediaCollection( - await Media.projectReport(projectReportId).findAll(), + await Media.for(projectReport).findAll(), ProjectReport.MEDIA ) as ProjectReportMedia) }; diff --git a/apps/entity-service/src/entities/processors/project.processor.spec.ts b/apps/entity-service/src/entities/processors/project.processor.spec.ts index 22324fbc..6650e3e2 100644 --- a/apps/entity-service/src/entities/processors/project.processor.spec.ts +++ b/apps/entity-service/src/entities/processors/project.processor.spec.ts @@ -26,12 +26,13 @@ import { flatten, reverse, sortBy, sum, sumBy } from "lodash"; import { DateTime } from "luxon"; import { faker } from "@faker-js/faker"; import { FrameworkKey } from "@terramatch-microservices/database/constants/framework"; -import { BadRequestException } from "@nestjs/common"; +import { BadRequestException, UnauthorizedException } from "@nestjs/common"; import { FULL_TIME, PART_TIME } from "@terramatch-microservices/database/constants/demographic-collections"; import { PolicyService } from "@terramatch-microservices/common"; import { ProjectLightDto } from "../dto/project.dto"; import { buildJsonApi } from "@terramatch-microservices/common/util"; import { LocalizationService } from "@terramatch-microservices/common/localization/localization.service"; +import { EntityProcessor } from "./entity-processor"; describe("ProjectProcessor", () => { let processor: ProjectProcessor; @@ -250,6 +251,29 @@ describe("ProjectProcessor", () => { }); }); + describe("update", () => { + it("can update the isTest flag", async () => { + const project = await ProjectFactory.create({ isTest: false }); + policyService.getPermissions.mockResolvedValue(["projects-read"]); + await expect(processor.update(project, { isTest: false })).rejects.toThrow(UnauthorizedException); + expect(project.isTest).toBe(false); + await processor.update(project, {}); + expect(project.isTest).toBe(false); + + policyService.getPermissions.mockResolvedValue([`framework-${project.frameworkKey}`]); + await processor.update(project, { isTest: true }); + expect(project.isTest).toBe(true); + }); + + it("should call super.update", async () => { + const project = await ProjectFactory.create(); + const spy = jest.spyOn(EntityProcessor.prototype, "update"); + const update = { feedback: "foo" }; + await processor.update(project, update); + expect(spy).toHaveBeenCalledWith(project, update); + }); + }); + describe("DTOs", () => { it("includes calculated fields in ProjectLightDto", async () => { const org = await OrganisationFactory.create(); diff --git a/apps/entity-service/src/entities/processors/project.processor.ts b/apps/entity-service/src/entities/processors/project.processor.ts index 9d300fa9..ce321d4c 100644 --- a/apps/entity-service/src/entities/processors/project.processor.ts +++ b/apps/entity-service/src/entities/processors/project.processor.ts @@ -26,11 +26,17 @@ import { } from "../dto/project.dto"; import { EntityQueryDto } from "../dto/entity-query.dto"; import { FrameworkKey } from "@terramatch-microservices/database/constants/framework"; -import { BadRequestException } from "@nestjs/common"; +import { BadRequestException, UnauthorizedException } from "@nestjs/common"; import { ProcessableEntity } from "../entities.service"; import { DocumentBuilder } from "@terramatch-microservices/common/util"; +import { ProjectUpdateAttributes } from "../dto/entity-update.dto"; -export class ProjectProcessor extends EntityProcessor { +export class ProjectProcessor extends EntityProcessor< + Project, + ProjectLightDto, + ProjectFullDto, + ProjectUpdateAttributes +> { readonly LIGHT_DTO = ProjectLightDto; readonly FULL_DTO = ProjectFullDto; @@ -98,6 +104,19 @@ export class ProjectProcessor extends EntityProcessor { }: { permissions?: string[]; sortField?: string; sortUp?: boolean; total?: number } = {} ) { policyService.getPermissions.mockResolvedValue(permissions); - const { models, paginationTotal } = await processor.findMany(query as EntityQueryDto, userId); + const { models, paginationTotal } = await processor.findMany(query as EntityQueryDto); expect(models.length).toBe(expected.length); expect(paginationTotal).toBe(total); diff --git a/apps/entity-service/src/entities/processors/site-report.processor.ts b/apps/entity-service/src/entities/processors/site-report.processor.ts index 6dfbe73e..161a2bce 100644 --- a/apps/entity-service/src/entities/processors/site-report.processor.ts +++ b/apps/entity-service/src/entities/processors/site-report.processor.ts @@ -7,7 +7,7 @@ import { SiteReport, TreeSpecies } from "@terramatch-microservices/database/entities"; -import { EntityProcessor } from "./entity-processor"; +import { ReportProcessor } from "./entity-processor"; import { EntityQueryDto } from "../dto/entity-query.dto"; import { Includeable, Op } from "sequelize"; import { BadRequestException } from "@nestjs/common"; @@ -18,8 +18,14 @@ import { SiteReportLightDto, SiteReportMedia } from "../dto/site-report.dto"; +import { ReportUpdateAttributes } from "../dto/entity-update.dto"; -export class SiteReportProcessor extends EntityProcessor { +export class SiteReportProcessor extends ReportProcessor< + SiteReport, + SiteReportLightDto, + SiteReportFullDto, + ReportUpdateAttributes +> { readonly LIGHT_DTO = SiteReportLightDto; readonly FULL_DTO = SiteReportFullDto; @@ -54,7 +60,7 @@ export class SiteReportProcessor extends EntityProcessor 0) { builder.where({ frameworkKey: { [Op.in]: frameworkPermissions } }); } else if (permissions?.includes("manage-own")) { - builder.where({ "$site.project.id$": { [Op.in]: ProjectUser.userProjectsSubquery(userId) } }); + builder.where({ + "$site.project.id$": { [Op.in]: ProjectUser.userProjectsSubquery(this.entitiesService.userId) } + }); } else if (permissions?.includes("projects-manage")) { - builder.where({ "$site.project.id$": { [Op.in]: ProjectUser.projectsManageSubquery(userId) } }); + builder.where({ + "$site.project.id$": { [Op.in]: ProjectUser.projectsManageSubquery(this.entitiesService.userId) } + }); } const associationFieldMap = { @@ -146,7 +156,7 @@ export class SiteReportProcessor extends EntityProcessor { +export class SiteProcessor extends EntityProcessor { readonly LIGHT_DTO = SiteLightDto; readonly FULL_DTO = SiteFullDto; + readonly APPROVAL_STATUSES = [APPROVED, NEEDS_MORE_INFORMATION, RESTORATION_IN_PROGRESS]; + async findOne(uuid: string) { return await Site.findOne({ where: { uuid }, @@ -123,6 +130,72 @@ export class SiteProcessor extends EntityProcessor> { + if (siteUuids.length === 0) return {}; + + const polygons = await SitePolygon.findAll({ + where: { + siteUuid: { [Op.in]: siteUuids }, + status: "approved", + isActive: true, + deletedAt: null + }, + attributes: ["siteUuid", "calcArea"] + }); + + const hectaresMap: Record = {}; + const polygonsBySite = groupBy(polygons, "siteUuid"); + + for (const siteUuid of siteUuids) { + const sitesPolygons = polygonsBySite[siteUuid] ?? []; + hectaresMap[siteUuid] = sumBy(sitesPolygons, polygon => Number(polygon.calcArea) ?? 0); + } + + return hectaresMap; + } + + private async getTreesPlantedCount(sites: Site[]): Promise> { + if (sites.length === 0) return {}; + + const siteIds = sites.map(site => site.id); + const approvedReports = await SiteReport.findAll({ + where: { + siteId: { [Op.in]: siteIds }, + status: "approved" + }, + attributes: ["id", "siteId"] + }); + + if (approvedReports.length === 0) { + return sites.reduce((acc, site) => ({ ...acc, [site.uuid]: 0 }), {}); + } + + const reportIds = approvedReports.map(r => r.id); + const reportBySiteId = groupBy(approvedReports, "siteId"); + + const treesPlanted = await TreeSpecies.findAll({ + where: { + speciesableId: { [Op.in]: reportIds }, + speciesableType: SiteReport.LARAVEL_TYPE, + collection: "tree-planted", + hidden: false + }, + attributes: ["speciesableId", "amount"] + }); + + const result: Record = {}; + + for (const site of sites) { + const siteId = site.id; + const siteReports = reportBySiteId[siteId] ?? []; + const siteReportIds = siteReports.map(r => r.id); + const treesForSite = treesPlanted.filter(t => siteReportIds.includes(t.speciesableId)); + result[site.uuid] = sumBy(treesForSite, "amount") ?? 0; + } + + return result; + } + async getFullDto(site: Site) { const siteId = site.id; @@ -137,8 +210,11 @@ export class SiteProcessor extends EntityProcessor { + if (sites.length === 0) return []; + + const siteUuids = sites.map(site => site.uuid); + + const [hectaresData, treesPlantedData] = await Promise.all([ + this.getHectaresRestoredSum(siteUuids), + this.getTreesPlantedCount(sites) + ]); - return { id: site.uuid, dto: new SiteLightDto(site, { treesPlantedCount }) }; + return sites.map(site => ({ + id: site.uuid, + dto: new SiteLightDto(site, { + treesPlantedCount: treesPlantedData[site.uuid] ?? 0, + totalHectaresRestoredSum: hectaresData[site.uuid] ?? 0 + }) + })); } protected async getWorkdayCount(siteId: number, useDemographicsCutoff = false) { @@ -216,7 +314,6 @@ export class SiteProcessor extends EntityProcessor this.db.pingCheck("database", { connection })]); - } finally { - User.sequelize.connectionManager.releaseConnection(connection); - } - } -} diff --git a/apps/entity-service/src/health/health.module.ts b/apps/entity-service/src/health/health.module.ts deleted file mode 100644 index 0f68be79..00000000 --- a/apps/entity-service/src/health/health.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/job-service/src/app.module.ts b/apps/job-service/src/app.module.ts index 345b2516..872883d6 100644 --- a/apps/job-service/src/app.module.ts +++ b/apps/job-service/src/app.module.ts @@ -1,13 +1,12 @@ import { Module } from "@nestjs/common"; -import { DatabaseModule } from "@terramatch-microservices/database"; import { CommonModule } from "@terramatch-microservices/common"; import { DelayedJobsController } from "./jobs/delayed-jobs.controller"; -import { HealthModule } from "./health/health.module"; import { SentryGlobalFilter, SentryModule } from "@sentry/nestjs/setup"; import { APP_FILTER } from "@nestjs/core"; +import { HealthModule } from "@terramatch-microservices/common/health/health.module"; @Module({ - imports: [SentryModule.forRoot(), DatabaseModule, CommonModule, HealthModule], + imports: [SentryModule.forRoot(), CommonModule, HealthModule], controllers: [DelayedJobsController], providers: [ { diff --git a/apps/job-service/src/health/health.controller.ts b/apps/job-service/src/health/health.controller.ts deleted file mode 100644 index 950e84da..00000000 --- a/apps/job-service/src/health/health.controller.ts +++ /dev/null @@ -1,23 +0,0 @@ -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/job-service/src/health/health.module.ts b/apps/job-service/src/health/health.module.ts deleted file mode 100644 index 0f68be79..00000000 --- a/apps/job-service/src/health/health.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/job-service/src/jobs/delayed-jobs.controller.spec.ts b/apps/job-service/src/jobs/delayed-jobs.controller.spec.ts index 62c0d858..2cba28af 100644 --- a/apps/job-service/src/jobs/delayed-jobs.controller.spec.ts +++ b/apps/job-service/src/jobs/delayed-jobs.controller.spec.ts @@ -224,7 +224,7 @@ describe("DelayedJobsController", () => { await expect(controller.bulkUpdateJobs(payload, request)).rejects.toThrow(NotFoundException); }); - it('should not update jobs with status "pending"', async () => { + it('should update jobs with status "pending"', async () => { const authenticatedUserId = 130999; const pendingJob = await DelayedJob.create({ uuid: uuidv4(), @@ -234,18 +234,36 @@ describe("DelayedJobsController", () => { metadata: { entity_name: "TestEntityPending" } // Adding entity_name }); + const pendingJob2 = await DelayedJob.create({ + uuid: uuidv4(), + createdBy: authenticatedUserId, + isAcknowledged: false, + status: "pending", + metadata: { entity_name: "TestEntityPending2" } // Adding entity_name + }); + const payload: DelayedJobBulkUpdateBodyDto = { data: [ { type: "delayedJobs", uuid: pendingJob.uuid, attributes: { isAcknowledged: true } + }, + { + type: "delayedJobs", + uuid: pendingJob2.uuid, + attributes: { isAcknowledged: true } } ] }; const request = { authenticatedUserId }; - await expect(controller.bulkUpdateJobs(payload, request)).rejects.toThrow(NotFoundException); + const result = await controller.bulkUpdateJobs(payload, request); + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toBe(pendingJob.uuid); + expect(result.data[0].attributes.entityName).toBe("TestEntityPending"); + expect(result.data[1].id).toBe(pendingJob2.uuid); + expect(result.data[1].attributes.entityName).toBe("TestEntityPending2"); }); }); diff --git a/apps/job-service/src/jobs/delayed-jobs.controller.ts b/apps/job-service/src/jobs/delayed-jobs.controller.ts index f047340d..f283d275 100644 --- a/apps/job-service/src/jobs/delayed-jobs.controller.ts +++ b/apps/job-service/src/jobs/delayed-jobs.controller.ts @@ -90,8 +90,7 @@ export class DelayedJobsController { const jobs = await DelayedJob.findAll({ where: { uuid: { [Op.in]: jobUpdates.map(({ uuid }) => uuid) }, - createdBy: authenticatedUserId, - status: { [Op.ne]: "pending" } + createdBy: authenticatedUserId }, order: [["createdAt", "DESC"]] }); diff --git a/apps/research-service/src/app.module.ts b/apps/research-service/src/app.module.ts index feb5d57c..a6b1f8c0 100644 --- a/apps/research-service/src/app.module.ts +++ b/apps/research-service/src/app.module.ts @@ -1,14 +1,13 @@ 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"; import { SitePolygonsService } from "./site-polygons/site-polygons.service"; import { APP_FILTER } from "@nestjs/core"; import { SentryGlobalFilter, SentryModule } from "@sentry/nestjs/setup"; +import { HealthModule } from "@terramatch-microservices/common/health/health.module"; @Module({ - imports: [SentryModule.forRoot(), DatabaseModule, CommonModule, HealthModule], + imports: [SentryModule.forRoot(), CommonModule, HealthModule], controllers: [SitePolygonsController], providers: [ { diff --git a/apps/research-service/src/health/health.controller.ts b/apps/research-service/src/health/health.controller.ts deleted file mode 100644 index 950e84da..00000000 --- a/apps/research-service/src/health/health.controller.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 0f68be79..00000000 --- a/apps/research-service/src/health/health.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/unified-database-service/src/airtable/airtable.module.ts b/apps/unified-database-service/src/airtable/airtable.module.ts index 2bc38cbe..30d39c72 100644 --- a/apps/unified-database-service/src/airtable/airtable.module.ts +++ b/apps/unified-database-service/src/airtable/airtable.module.ts @@ -1,35 +1,19 @@ -import { DatabaseModule } from "@terramatch-microservices/database"; import { CommonModule } from "@terramatch-microservices/common"; -import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ConfigModule } from "@nestjs/config"; import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; import { AirtableService } from "./airtable.service"; import { AirtableProcessor } from "./airtable.processor"; -import { QueueHealthIndicator } from "./queue-health.indicator"; -import { TerminusModule } from "@nestjs/terminus"; +import { DataApiModule } from "@terramatch-microservices/data-api"; @Module({ imports: [ - DatabaseModule, CommonModule, ConfigModule.forRoot({ isGlobal: true }), - BullModule.forRootAsync({ - imports: [ConfigModule.forRoot({ isGlobal: true })], - inject: [ConfigService], - useFactory: (configService: ConfigService) => ({ - connection: { - host: configService.get("REDIS_HOST"), - port: configService.get("REDIS_PORT"), - prefix: "unified-database-service", - // Use TLS in AWS - ...(process.env.NODE_ENV !== "development" ? { tls: {} } : null) - } - }) - }), BullModule.registerQueue({ name: "airtable" }), - TerminusModule + DataApiModule ], - providers: [AirtableService, AirtableProcessor, QueueHealthIndicator], - exports: [AirtableService, QueueHealthIndicator] + providers: [AirtableService, AirtableProcessor], + exports: [AirtableService] }) export class AirtableModule {} diff --git a/apps/unified-database-service/src/airtable/airtable.processor.spec.ts b/apps/unified-database-service/src/airtable/airtable.processor.spec.ts index 7bb29e06..c6814624 100644 --- a/apps/unified-database-service/src/airtable/airtable.processor.spec.ts +++ b/apps/unified-database-service/src/airtable/airtable.processor.spec.ts @@ -5,6 +5,7 @@ import { createMock } from "@golevelup/ts-jest"; import { InternalServerErrorException, NotImplementedException } from "@nestjs/common"; import { Job } from "bullmq"; import { SlackService } from "@terramatch-microservices/common/slack/slack.service"; +import { DataApiService } from "@terramatch-microservices/data-api"; jest.mock("airtable", () => jest.fn(() => ({ @@ -20,7 +21,8 @@ describe("AirtableProcessor", () => { providers: [ AirtableProcessor, { provide: ConfigService, useValue: createMock() }, - { provide: SlackService, useValue: createMock() } + { provide: SlackService, useValue: createMock() }, + { provide: DataApiService, useValue: createMock() } ] }).compile(); diff --git a/apps/unified-database-service/src/airtable/airtable.processor.ts b/apps/unified-database-service/src/airtable/airtable.processor.ts index 9b7bfe96..1938bd63 100644 --- a/apps/unified-database-service/src/airtable/airtable.processor.ts +++ b/apps/unified-database-service/src/airtable/airtable.processor.ts @@ -21,6 +21,7 @@ import { import * as Sentry from "@sentry/node"; import { SlackService } from "@terramatch-microservices/common/slack/slack.service"; import { TMLogger } from "@terramatch-microservices/common/util/tm-logger"; +import { DataApiService } from "@terramatch-microservices/data-api"; export const AIRTABLE_ENTITIES = { applications: ApplicationEntity, @@ -66,7 +67,11 @@ export class AirtableProcessor extends WorkerHost { private readonly logger = new TMLogger(AirtableProcessor.name); private readonly base: Airtable.Base; - constructor(private readonly config: ConfigService, private readonly slack: SlackService) { + constructor( + private readonly config: ConfigService, + private readonly slack: SlackService, + private readonly dataApi: DataApiService + ) { super(); this.base = new Airtable({ apiKey: this.config.get("AIRTABLE_API_KEY") }).base(this.config.get("AIRTABLE_BASE_ID")); } @@ -104,7 +109,7 @@ export class AirtableProcessor extends WorkerHost { throw new InternalServerErrorException(`Entity mapping not found for entity type ${entityType}`); } - const entity = new entityClass(); + const entity = new entityClass(this.dataApi); await entity.updateBase(this.base, { startPage, updatedSince }); this.logger.log(`Completed entity update: ${JSON.stringify({ entityType, updatedSince })}`); @@ -119,7 +124,7 @@ export class AirtableProcessor extends WorkerHost { throw new InternalServerErrorException(`Entity mapping not found for entity type ${entityType}`); } - const entity = new entityClass(); + const entity = new entityClass(this.dataApi); await entity.deleteStaleRecords(this.base, deletedSince); this.logger.log(`Completed entity delete: ${JSON.stringify({ entityType, deletedSince })}`); diff --git a/apps/unified-database-service/src/airtable/airtable.service.spec.ts b/apps/unified-database-service/src/airtable/airtable.service.spec.ts index 46248155..3cca68f0 100644 --- a/apps/unified-database-service/src/airtable/airtable.service.spec.ts +++ b/apps/unified-database-service/src/airtable/airtable.service.spec.ts @@ -3,6 +3,7 @@ import { AirtableService } from "./airtable.service"; import { Queue } from "bullmq"; import { Test } from "@nestjs/testing"; import { getQueueToken } from "@nestjs/bullmq"; +import { DataApiService } from "@terramatch-microservices/data-api"; describe("AirtableService", () => { let service: AirtableService; @@ -15,6 +16,10 @@ describe("AirtableService", () => { { provide: getQueueToken("airtable"), useValue: (queue = createMock()) + }, + { + provide: DataApiService, + useValue: createMock() } ] }).compile(); diff --git a/apps/unified-database-service/src/airtable/entities/airtable-entity.spec.ts b/apps/unified-database-service/src/airtable/entities/airtable-entity.spec.ts index 3726c19c..04e92a7e 100644 --- a/apps/unified-database-service/src/airtable/entities/airtable-entity.spec.ts +++ b/apps/unified-database-service/src/airtable/entities/airtable-entity.spec.ts @@ -5,6 +5,7 @@ import { Demographic, DemographicEntry, Framework, + FundingProgramme, Nursery, NurseryReport, Organisation, @@ -39,10 +40,12 @@ import { ApplicationEntity, DemographicEntity, DemographicEntryEntity, + FundingProgrammeEntity, NurseryEntity, NurseryReportEntity, OrganisationEntity, ProjectEntity, + ProjectPitchEntity, ProjectReportEntity, SiteEntity, SiteReportEntity, @@ -53,6 +56,14 @@ import { Model } from "sequelize-typescript"; import { FrameworkKey } from "@terramatch-microservices/database/constants/framework"; import { FindOptions, Op } from "sequelize"; import { DateTime } from "luxon"; +import { DataApiService } from "@terramatch-microservices/data-api"; +import { createMock } from "@golevelup/ts-jest"; +import { + COUNTRIES, + gadmLevel0Mock, + gadmLevel1Mock, + STATES +} from "@terramatch-microservices/database/util/gadm-mock-data"; const airtableUpdate = jest.fn, [{ fields: object }[], object]>(() => Promise.resolve()); const airtableSelectFirstPage = jest.fn, never>(() => Promise.resolve([])); @@ -64,6 +75,8 @@ const Base = jest.fn(() => ({ destroy: airtableDestroy })) as unknown as Airtable.Base; +const dataApi = createMock({ gadmLevel0: gadmLevel0Mock, gadmLevel1: gadmLevel1Mock }); + const mapEntityColumns = jest.fn(() => Promise.resolve({})); export class StubEntity extends AirtableEntity { readonly TABLE_NAME = "stubs"; @@ -121,18 +134,18 @@ describe("AirtableEntity", () => { it("re-raises mapping errors", async () => { mapEntityColumns.mockRejectedValue(new Error("mapping error")); - await expect(new StubEntity().updateBase(null, { startPage: 0 })).rejects.toThrow("mapping error"); + await expect(new StubEntity(dataApi).updateBase(null, { startPage: 0 })).rejects.toThrow("mapping error"); mapEntityColumns.mockReset(); }); it("re-raises airtable errors", async () => { airtableUpdate.mockRejectedValue(new Error("airtable error")); - await expect(new StubEntity().updateBase(Base)).rejects.toThrow("airtable error"); + await expect(new StubEntity(dataApi).updateBase(Base)).rejects.toThrow("airtable error"); airtableUpdate.mockReset(); }); it("includes the updatedSince timestamp in the query", async () => { - const entity = new StubEntity(); + const entity = new StubEntity(dataApi); const spy = jest.spyOn(entity as never, "getUpdatePageFindOptions") as jest.SpyInstance>; const updatedSince = new Date(); await entity.updateBase(Base, { updatedSince }); @@ -143,7 +156,7 @@ describe("AirtableEntity", () => { }); it("skips the updatedSince timestamp if the model doesn't support it", async () => { - const entity = new StubEntity(); + const entity = new StubEntity(dataApi); // @ts-expect-error overriding readonly property for test. (entity as never).SUPPORTS_UPDATED_SINCE = false; const spy = jest.spyOn(entity as never, "getUpdatePageFindOptions") as jest.SpyInstance>; @@ -180,13 +193,13 @@ describe("AirtableEntity", () => { it("re-raises search errors", async () => { airtableSelectFirstPage.mockRejectedValue(new Error("select error")); - await expect(new SiteEntity().deleteStaleRecords(Base, deletedSince)).rejects.toThrow("select error"); + await expect(new SiteEntity(dataApi).deleteStaleRecords(Base, deletedSince)).rejects.toThrow("select error"); }); it("re-raises delete errors", async () => { airtableSelectFirstPage.mockResolvedValue([{ id: "fakeid", fields: { uuid: "fakeuuid" } }]); airtableDestroy.mockRejectedValue(new Error("delete error")); - await expect(new SiteEntity().deleteStaleRecords(Base, deletedSince)).rejects.toThrow("delete error"); + await expect(new SiteEntity(dataApi).deleteStaleRecords(Base, deletedSince)).rejects.toThrow("delete error"); airtableDestroy.mockReset(); }); @@ -196,7 +209,7 @@ describe("AirtableEntity", () => { fields: { uuid } })); airtableSelectFirstPage.mockResolvedValue(searchResult); - await new SiteEntity().deleteStaleRecords(Base, deletedSince); + await new SiteEntity(dataApi).deleteStaleRecords(Base, deletedSince); expect(airtableSelect).toHaveBeenCalledTimes(1); expect(airtableSelect).toHaveBeenCalledWith( expect.objectContaining({ @@ -254,7 +267,7 @@ describe("AirtableEntity", () => { it("sends all records to airtable", async () => { await testAirtableUpdates( - new ApplicationEntity(), + new ApplicationEntity(dataApi), applications, ({ uuid, organisationUuid, fundingProgrammeUuid }) => ({ fields: { @@ -327,7 +340,7 @@ describe("AirtableEntity", () => { it("sends all records to airtable", async () => { await testAirtableUpdates( - new DemographicEntity(), + new DemographicEntity(dataApi), demographics, ({ uuid, collection, demographicalType, demographicalId }) => ({ fields: { @@ -385,7 +398,7 @@ describe("AirtableEntity", () => { it("sends all records to airtable", async () => { await testAirtableUpdates( - new DemographicEntryEntity(), + new DemographicEntryEntity(dataApi), entries, ({ id, type, subtype, name, amount, demographicId }) => ({ fields: { @@ -401,6 +414,21 @@ describe("AirtableEntity", () => { }); }); + describe("FundingProgrammeEntity", () => { + let fundingProgrammes: FundingProgramme[]; + + beforeAll(async () => { + await FundingProgramme.truncate(); + fundingProgrammes = await FundingProgrammeFactory.createMany(2); + }); + + it("sends all records to airtable", async () => { + await testAirtableUpdates(new FundingProgrammeEntity(dataApi), fundingProgrammes, ({ uuid, name }) => ({ + fields: { uuid, name } + })); + }); + }); + describe("NurseryEntity", () => { let projectUuids: Record; let nurseries: Nursery[]; @@ -422,7 +450,7 @@ describe("AirtableEntity", () => { }); it("sends all records to airtable", async () => { - await testAirtableUpdates(new NurseryEntity(), nurseries, ({ uuid, name, projectId, status }) => ({ + await testAirtableUpdates(new NurseryEntity(dataApi), nurseries, ({ uuid, name, projectId, status }) => ({ fields: { uuid, name, @@ -454,7 +482,7 @@ describe("AirtableEntity", () => { }); it("sends all records to airtable", async () => { - await testAirtableUpdates(new NurseryReportEntity(), reports, ({ uuid, nurseryId, status, dueAt }) => ({ + await testAirtableUpdates(new NurseryReportEntity(dataApi), reports, ({ uuid, nurseryId, status, dueAt }) => ({ fields: { uuid, nurseryUuid: nurseryUuids[nurseryId], @@ -479,7 +507,7 @@ describe("AirtableEntity", () => { }); it("sends all records to airtable", async () => { - await testAirtableUpdates(new OrganisationEntity(), organisations, ({ uuid, name, status }) => ({ + await testAirtableUpdates(new OrganisationEntity(dataApi), organisations, ({ uuid, name, status }) => ({ fields: { uuid, name, @@ -574,7 +602,7 @@ describe("AirtableEntity", () => { it("sends all records to airtable", async () => { await testAirtableUpdates( - new ProjectEntity(), + new ProjectEntity(dataApi), projects, ({ uuid, name, frameworkKey, organisationId, applicationId }) => ({ fields: { @@ -590,6 +618,27 @@ describe("AirtableEntity", () => { }); }); + describe("ProjectPitchEntity", () => { + let pitches: ProjectPitch[]; + + beforeAll(async () => { + await ProjectPitch.truncate(); + pitches = await ProjectPitchFactory.createMany(3); + }); + + it("sends all records to airtable", async () => { + await testAirtableUpdates(new ProjectPitchEntity(dataApi), pitches, ({ uuid, projectCountry, states }) => ({ + fields: { + uuid, + projectCountry, + projectCountryName: COUNTRIES[projectCountry], + states, + stateNames: states.map(state => STATES[state.split(".")[0]][state]) + } + })); + }); + }); + describe("ProjectReportEntity", () => { let projectUuids: Record; let reports: ProjectReport[]; @@ -654,7 +703,7 @@ describe("AirtableEntity", () => { }); it("sends all records to airtable", async () => { - await testAirtableUpdates(new ProjectReportEntity(), reports, ({ uuid, projectId, status, dueAt }) => ({ + await testAirtableUpdates(new ProjectReportEntity(dataApi), reports, ({ uuid, projectId, status, dueAt }) => ({ fields: { uuid, projectUuid: projectUuids[projectId], @@ -684,7 +733,7 @@ describe("AirtableEntity", () => { }); it("sends all records to airtable", async () => { - await testAirtableUpdates(new SiteEntity(), sites, ({ uuid, name, projectId, status }) => ({ + await testAirtableUpdates(new SiteEntity(dataApi), sites, ({ uuid, name, projectId, status }) => ({ fields: { uuid, name, @@ -720,7 +769,7 @@ describe("AirtableEntity", () => { }); it("sends all records to airtable", async () => { - await testAirtableUpdates(new SiteReportEntity(), reports, ({ id, uuid, siteId, status, dueAt }) => ({ + await testAirtableUpdates(new SiteReportEntity(dataApi), reports, ({ id, uuid, siteId, status, dueAt }) => ({ fields: { uuid, siteUuid: siteUuids[siteId], @@ -796,7 +845,7 @@ describe("AirtableEntity", () => { it("sends all records to airtable", async () => { await testAirtableUpdates( - new TreeSpeciesEntity(), + new TreeSpeciesEntity(dataApi), trees, ({ uuid, name, amount, collection, speciesableType, speciesableId }) => ({ fields: { @@ -826,7 +875,7 @@ describe("AirtableEntity", () => { super.getDeletePageFindOptions(deletedSince, page); } const deletedSince = new Date(); - const result = new Test().getDeletePageFindOptions(deletedSince, 0); + const result = new Test(dataApi).getDeletePageFindOptions(deletedSince, 0); expect(result.where[Op.or]).not.toBeNull(); expect(result.where[Op.or]?.[Op.and]?.updatedAt?.[Op.gte]).toBe(deletedSince); }); diff --git a/apps/unified-database-service/src/airtable/entities/airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/airtable-entity.ts index ffd46347..4d64ce2a 100644 --- a/apps/unified-database-service/src/airtable/entities/airtable-entity.ts +++ b/apps/unified-database-service/src/airtable/entities/airtable-entity.ts @@ -1,9 +1,11 @@ import { Model, ModelCtor, ModelType } from "sequelize-typescript"; -import { cloneDeep, flatten, groupBy, isEmpty, isObject, isString, uniq } from "lodash"; +import { cloneDeep, flatten, groupBy, isEmpty, isObject, isString, keyBy, mapValues, merge, uniq } from "lodash"; import { Attributes, FindOptions, Op, WhereOptions } from "sequelize"; import Airtable from "airtable"; import { UuidModel } from "@terramatch-microservices/database/types/util"; import { TMLogger } from "@terramatch-microservices/common/util/tm-logger"; +import { DataApiService } from "@terramatch-microservices/data-api"; +import { Dictionary } from "factory-girl-ts"; // The Airtable API only supports bulk updates of up to 10 rows. const AIRTABLE_PAGE_SIZE = 10; @@ -23,6 +25,8 @@ export abstract class AirtableEntity, Associa protected readonly logger = new TMLogger(AirtableEntity.name); + constructor(protected dataApi: DataApiService) {} + /** * If an airtable entity provides a concrete type for Associations, this method should be overridden * to execute the necessary DB queries and provide a mapping of record number to concrete instance @@ -159,7 +163,7 @@ export abstract class AirtableEntity, Associa try { const records = (await this.MODEL.findAll( this.getDeletePageFindOptions(deletedSince, page) - )) as unknown as UuidModel[]; + )) as unknown as UuidModel[]; // Page had no records, halt processing. if (records.length === 0) return false; @@ -249,6 +253,23 @@ export abstract class AirtableEntity, Associa return associations; } + + protected _gadmCountryNames: Dictionary; + protected async gadmCountryNames() { + return (this._gadmCountryNames ??= mapValues(keyBy(await this.dataApi.gadmLevel0(), "iso"), "name")); + } + + protected _gadmStateNames: Dictionary> = {}; + protected async gadmStateNames(countryIsos: string[]) { + return merge( + {}, + ...(await Promise.all( + countryIsos.map(async iso => { + return (this._gadmStateNames[iso] ??= mapValues(keyBy(await this.dataApi.gadmLevel1(iso), "id"), "name")); + }) + )) + ); + } } export type Include = { @@ -324,7 +345,7 @@ const selectIncludes = , A>(columns: ColumnMapping[]) = return mapping.include.reduce(mergeInclude, includes); }, [] as Include[]); -export const commonEntityColumns = , A = Record>(adminSiteType: string) => +export const commonEntityColumns = >(adminSiteType: string) => [ "uuid", "createdAt", diff --git a/apps/unified-database-service/src/airtable/entities/organisation.airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/organisation.airtable-entity.ts index 00b3de15..d298d111 100644 --- a/apps/unified-database-service/src/airtable/entities/organisation.airtable-entity.ts +++ b/apps/unified-database-service/src/airtable/entities/organisation.airtable-entity.ts @@ -1,8 +1,15 @@ -import { AirtableEntity, ColumnMapping, commonEntityColumns } from "./airtable-entity"; +import { AirtableEntity, associatedValueColumn, ColumnMapping, commonEntityColumns } from "./airtable-entity"; import { Organisation } from "@terramatch-microservices/database/entities"; +import { filter, flatten, uniq } from "lodash"; -const COLUMNS: ColumnMapping[] = [ - ...commonEntityColumns("organisation"), +type OrganisationAssociations = { + hqCountryName: string; + countryNames: string[]; + stateNames: string[]; +}; + +const COLUMNS: ColumnMapping[] = [ + ...commonEntityColumns("organisation"), "status", "type", "private", @@ -15,10 +22,12 @@ const COLUMNS: ColumnMapping[] = [ "hqState", "hqZipcode", "hqCountry", + associatedValueColumn("hqCountryName", "hqCountry"), "leadershipTeamTxt", "foundingDate", "description", "countries", + associatedValueColumn("countryNames", "countries"), "languages", "treeCareApproach", "relevantExperienceYears", @@ -51,6 +60,7 @@ const COLUMNS: ColumnMapping[] = [ "engagementYouth", "currency", "states", + associatedValueColumn("stateNames", "states"), "district", "accountNumber1", "accountNumber2", @@ -87,8 +97,28 @@ const COLUMNS: ColumnMapping[] = [ "additionalComments" ]; -export class OrganisationEntity extends AirtableEntity { +export class OrganisationEntity extends AirtableEntity { readonly TABLE_NAME = "Organisations"; readonly COLUMNS = COLUMNS; readonly MODEL = Organisation; + + async loadAssociations(organisations: Organisation[]) { + const countryNames = await this.gadmCountryNames(); + const stateCountries = filter( + uniq(flatten(organisations.map(({ states }) => states?.map(state => state.split(".")[0])))) + ); + const stateNames = await this.gadmStateNames(stateCountries); + + return organisations.reduce( + (associations, { id, hqCountry, countries, states }) => ({ + ...associations, + [id]: { + hqCountryName: hqCountry == null ? null : countryNames[hqCountry], + countryNames: filter(countries?.map(country => countryNames[country])), + stateNames: filter(states?.map(state => stateNames[state])) + } + }), + {} as Record + ); + } } diff --git a/apps/unified-database-service/src/airtable/entities/project-pitch.airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/project-pitch.airtable-entity.ts index 4fdef71d..857ba34d 100644 --- a/apps/unified-database-service/src/airtable/entities/project-pitch.airtable-entity.ts +++ b/apps/unified-database-service/src/airtable/entities/project-pitch.airtable-entity.ts @@ -1,8 +1,14 @@ -import { AirtableEntity, ColumnMapping, commonEntityColumns } from "./airtable-entity"; +import { AirtableEntity, associatedValueColumn, ColumnMapping, commonEntityColumns } from "./airtable-entity"; import { ProjectPitch } from "@terramatch-microservices/database/entities"; +import { filter, flatten, uniq } from "lodash"; -const COLUMNS: ColumnMapping[] = [ - ...commonEntityColumns("pitch"), +type ProjectPitchAssociations = { + projectCountryName: string; + stateNames: string[]; +}; + +const COLUMNS: ColumnMapping[] = [ + ...commonEntityColumns("pitch"), "totalTrees", "totalHectares", "restorationInterventionTypes", @@ -10,6 +16,9 @@ const COLUMNS: ColumnMapping[] = [ "restorationStrategy", "projectObjectives", "projectCountry", + associatedValueColumn("projectCountryName", "projectCountry"), + "states", + associatedValueColumn("stateNames", "states"), "projectName", "projectBudget", "status", @@ -43,8 +52,27 @@ const COLUMNS: ColumnMapping[] = [ "goalTreesRestoredDirectSeeding" ]; -export class ProjectPitchEntity extends AirtableEntity { +export class ProjectPitchEntity extends AirtableEntity { readonly TABLE_NAME = "Project Pitches"; readonly COLUMNS = COLUMNS; readonly MODEL = ProjectPitch; + + async loadAssociations(pitches: ProjectPitch[]) { + const countryNames = await this.gadmCountryNames(); + const stateCountries = filter( + uniq(flatten(pitches.map(({ states }) => states?.map(state => state.split(".")[0])))) + ); + const stateNames = await this.gadmStateNames(stateCountries); + + return pitches.reduce( + (associations, { id, projectCountry, states }) => ({ + ...associations, + [id]: { + projectCountryName: projectCountry == null ? null : countryNames[projectCountry], + stateNames: filter(states?.map(state => stateNames[state])) + } + }), + {} as Record + ); + } } diff --git a/apps/unified-database-service/src/airtable/entities/project.airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/project.airtable-entity.ts index 7fdb2013..b9b4b1eb 100644 --- a/apps/unified-database-service/src/airtable/entities/project.airtable-entity.ts +++ b/apps/unified-database-service/src/airtable/entities/project.airtable-entity.ts @@ -6,8 +6,8 @@ import { Site, SitePolygon } from "@terramatch-microservices/database/entities"; -import { AirtableEntity, ColumnMapping, commonEntityColumns } from "./airtable-entity"; -import { flatten, groupBy } from "lodash"; +import { AirtableEntity, associatedValueColumn, ColumnMapping, commonEntityColumns } from "./airtable-entity"; +import { filter, flatten, groupBy, uniq } from "lodash"; const loadApprovedSites = async (projectIds: number[]) => groupBy( @@ -29,6 +29,8 @@ const loadSitePolygons = async (siteUuids: string[]) => type ProjectAssociations = { sitePolygons: SitePolygon[]; + countryName: string; + stateNames: string[]; }; const COLUMNS: ColumnMapping[] = [ @@ -53,6 +55,7 @@ const COLUMNS: ColumnMapping[] = [ }, "status", "country", + associatedValueColumn("countryName", "country"), "description", "plantingStartDate", "plantingEndDate", @@ -90,6 +93,7 @@ const COLUMNS: ColumnMapping[] = [ "descriptionOfProjectTimeline", "landholderCommEngage", "states", + associatedValueColumn("stateNames", "states"), "detailedInterventionTypes", "waterSource", "baselineBiodiversity", @@ -112,19 +116,25 @@ export class ProjectEntity extends AirtableEntity const approvedSites = await loadApprovedSites(projectIds); const allSiteUuids = flatten(Object.values(approvedSites).map(sites => sites.map(({ uuid }) => uuid))); const sitePolygons = await loadSitePolygons(allSiteUuids); + const countryNames = await this.gadmCountryNames(); + const stateCountries = filter( + uniq(flatten(projects.map(({ states }) => states?.map(state => state.split(".")[0])))) + ); + const stateNames = await this.gadmStateNames(stateCountries); - return projectIds.reduce((associations, projectId) => { - const sites = approvedSites[projectId] ?? []; - - return { + return projects.reduce( + (associations, { id, country, states }) => ({ ...associations, - [projectId]: { - sitePolygons: sites.reduce( + [id]: { + sitePolygons: (approvedSites[id] ?? []).reduce( (polygons, { uuid }) => [...polygons, ...(sitePolygons[uuid] ?? [])], [] as SitePolygon[] - ) + ), + countryName: country == null ? null : countryNames[country], + stateNames: filter(states?.map(state => stateNames[state])) } - }; - }, {} as Record); + }), + {} as Record + ); } } diff --git a/apps/unified-database-service/src/airtable/queue-health.indicator.ts b/apps/unified-database-service/src/airtable/queue-health.indicator.ts deleted file mode 100644 index fd54e802..00000000 --- a/apps/unified-database-service/src/airtable/queue-health.indicator.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { InjectQueue } from "@nestjs/bullmq"; -import { Injectable } from "@nestjs/common"; -import { HealthIndicatorService } from "@nestjs/terminus"; -import { Queue } from "bullmq"; - -@Injectable() -export class QueueHealthIndicator { - constructor( - @InjectQueue("airtable") private readonly airtableQueue: Queue, - private readonly healthIndicatorService: HealthIndicatorService - ) {} - - public async isHealthy() { - const indicator = this.healthIndicatorService.check("redis-queue:airtable"); - - const isHealthy = (await (await this.airtableQueue.client).ping()) === "PONG"; - if (!isHealthy) { - return indicator.down({ message: "Redis connection for Airtable queue unavailable" }); - } - - try { - const data = { - waitingJobs: await this.airtableQueue.getWaitingCount(), - totalJobs: await this.airtableQueue.count() - }; - - return indicator.up(data); - } catch (error) { - return indicator.down({ message: "Error fetching Airtable queue stats", error }); - } - } -} diff --git a/apps/unified-database-service/src/app.module.ts b/apps/unified-database-service/src/app.module.ts index 7e9ae861..c3b76597 100644 --- a/apps/unified-database-service/src/app.module.ts +++ b/apps/unified-database-service/src/app.module.ts @@ -1,20 +1,21 @@ import { Module } from "@nestjs/common"; -import { HealthModule } from "./health/health.module"; import { WebhookController } from "./webhook/webhook.controller"; import { AirtableModule } from "./airtable/airtable.module"; import { SentryGlobalFilter, SentryModule } from "@sentry/nestjs/setup"; -import { DatabaseModule } from "@terramatch-microservices/database"; import { APP_FILTER } from "@nestjs/core"; import { ScheduleModule } from "@nestjs/schedule"; +import { CommonModule } from "@terramatch-microservices/common"; +import { HealthModule } from "@terramatch-microservices/common/health/health.module"; @Module({ - imports: [SentryModule.forRoot(), ScheduleModule.forRoot(), DatabaseModule, HealthModule, AirtableModule], + imports: [ + SentryModule.forRoot(), + ScheduleModule.forRoot(), + CommonModule, + HealthModule.configure({ additionalQueues: ["airtable"] }), + AirtableModule + ], controllers: [WebhookController], - providers: [ - { - provide: APP_FILTER, - useClass: SentryGlobalFilter - } - ] + providers: [{ provide: APP_FILTER, useClass: SentryGlobalFilter }] }) export class AppModule {} diff --git a/apps/unified-database-service/src/health/health.module.ts b/apps/unified-database-service/src/health/health.module.ts deleted file mode 100644 index 1b7de1b3..00000000 --- a/apps/unified-database-service/src/health/health.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TerminusModule } from "@nestjs/terminus"; -import { HealthController } from "./health.controller"; -import { AirtableModule } from "../airtable/airtable.module"; - -@Module({ - imports: [TerminusModule, AirtableModule], - controllers: [HealthController] -}) -export class HealthModule {} diff --git a/apps/user-service/src/app.module.ts b/apps/user-service/src/app.module.ts index 0852c246..764afdb2 100644 --- a/apps/user-service/src/app.module.ts +++ b/apps/user-service/src/app.module.ts @@ -1,10 +1,8 @@ import { Module } from "@nestjs/common"; import { LoginController } from "./auth/login.controller"; import { AuthService } from "./auth/auth.service"; -import { DatabaseModule } from "@terramatch-microservices/database"; import { UsersController } from "./users/users.controller"; import { CommonModule } from "@terramatch-microservices/common"; -import { HealthModule } from "./health/health.module"; import { SentryGlobalFilter, SentryModule } from "@sentry/nestjs/setup"; import { APP_FILTER } from "@nestjs/core"; import { ResetPasswordController } from "./auth/reset-password.controller"; @@ -12,9 +10,10 @@ import { ResetPasswordService } from "./auth/reset-password.service"; import { VerificationUserController } from "./auth/verification-user.controller"; import { VerificationUserService } from "./auth/verification-user.service"; import { UserCreationService } from "./users/user-creation.service"; +import { HealthModule } from "@terramatch-microservices/common/health/health.module"; @Module({ - imports: [SentryModule.forRoot(), DatabaseModule, CommonModule, HealthModule], + imports: [SentryModule.forRoot(), CommonModule, HealthModule], controllers: [LoginController, UsersController, ResetPasswordController, VerificationUserController], providers: [ { diff --git a/apps/user-service/src/auth/reset-password.service.spec.ts b/apps/user-service/src/auth/reset-password.service.spec.ts index 4f09ca46..40001c68 100644 --- a/apps/user-service/src/auth/reset-password.service.spec.ts +++ b/apps/user-service/src/auth/reset-password.service.spec.ts @@ -8,14 +8,12 @@ import { ResetPasswordService } from "./reset-password.service"; import { BadRequestException, NotFoundException } from "@nestjs/common"; import { LocalizationKeyFactory } from "@terramatch-microservices/database/factories/localization-key.factory"; import { LocalizationService } from "@terramatch-microservices/common/localization/localization.service"; -import { TemplateService } from "@terramatch-microservices/common/email/template.service"; +import { TemplateService } from "@terramatch-microservices/common/templates/template.service"; describe("ResetPasswordService", () => { let service: ResetPasswordService; let jwtService: DeepMocked; let emailService: DeepMocked; - let localizationService: DeepMocked; - let templateService: DeepMocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -31,11 +29,11 @@ describe("ResetPasswordService", () => { }, { provide: LocalizationService, - useValue: (localizationService = createMock()) + useValue: createMock() }, { provide: TemplateService, - useValue: (templateService = createMock()) + useValue: createMock() } ] }).compile(); @@ -54,100 +52,33 @@ describe("ResetPasswordService", () => { ).rejects.toThrow(NotFoundException); }); - it("should throw when localizations not found", async () => { - const user = await UserFactory.create(); - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); - localizationService.getLocalizationKeys.mockReturnValue(Promise.resolve([])); - await expect( - service.sendResetPasswordEmail("user@gmail.com", "https://example.com/auth/reset-password") - ).rejects.toThrow(new NotFoundException("Localizations not found")); - }); - - it("should throw when localization body not found", async () => { - const user = await UserFactory.create(); - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); - const localizationSubject = await LocalizationKeyFactory.create({ - key: "reset-password.subject", - value: "Reset Password" - }); - localizationService.getLocalizationKeys.mockReturnValue(Promise.resolve([localizationSubject])); - await expect( - service.sendResetPasswordEmail("user@gmail.com", "https://example.com/auth/reset-password") - ).rejects.toThrow(new NotFoundException("Localization body not found")); - }); - - it("should throw when localization title not found", async () => { - const user = await UserFactory.create(); - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); - const localizationSubject = await LocalizationKeyFactory.create({ - key: "reset-password.subject", - value: "Reset Password" - }); - const localizationBody = await LocalizationKeyFactory.create({ - key: "reset-password.body", - value: "Reset your password by clicking on the following link: link" - }); - localizationService.getLocalizationKeys.mockReturnValue(Promise.resolve([localizationSubject, localizationBody])); - await expect( - service.sendResetPasswordEmail("user@gmail.com", "https://example.com/auth/reset-password") - ).rejects.toThrow(new NotFoundException("Localization title not found")); - }); - - it("should throw when localization CTA not found", async () => { - const user = await UserFactory.create(); - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); - const localizationSubject = await LocalizationKeyFactory.create({ - key: "reset-password.subject", - value: "Reset Password" - }); - const localizationBody = await LocalizationKeyFactory.create({ - key: "reset-password.body", - value: "Reset your password by clicking on the following link: link" - }); - const localizationTitle = await LocalizationKeyFactory.create({ - key: "reset-password.title", - value: "Reset Password" - }); - localizationService.getLocalizationKeys.mockReturnValue( - Promise.resolve([localizationSubject, localizationBody, localizationTitle]) - ); - await expect( - service.sendResetPasswordEmail("user@gmail.com", "https://example.com/auth/reset-password") - ).rejects.toThrow(new NotFoundException("Localization CTA not found")); - }); - it("should send a reset password email to the user", async () => { const user = await UserFactory.create(); - const localizationBody = await LocalizationKeyFactory.create({ + await LocalizationKeyFactory.create({ key: "reset-password.body", value: "Reset your password by clicking on the following link: link" }); - const localizationSubject = await LocalizationKeyFactory.create({ + await LocalizationKeyFactory.create({ key: "reset-password.subject", value: "Reset Password" }); - const localizationTitle = await LocalizationKeyFactory.create({ + await LocalizationKeyFactory.create({ key: "reset-password.title", value: "Reset Password" }); - const localizationCta = await LocalizationKeyFactory.create({ + await LocalizationKeyFactory.create({ key: "reset-password.cta", value: "Reset Password" }); - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); - localizationService.getLocalizationKeys.mockReturnValue( - Promise.resolve([localizationBody, localizationSubject, localizationTitle, localizationCta]) - ); const token = "fake token"; jwtService.signAsync.mockReturnValue(Promise.resolve(token)); - emailService.sendEmail.mockReturnValue(Promise.resolve()); - templateService.render.mockReturnValue("rendered template"); + emailService.sendI18nTemplateEmail.mockReturnValue(Promise.resolve()); - const result = await service.sendResetPasswordEmail("user@gmail.com", "https://example.com/auth/reset-password"); + const result = await service.sendResetPasswordEmail(user.emailAddress, "https://example.com/auth/reset-password"); expect(jwtService.signAsync).toHaveBeenCalled(); - expect(emailService.sendEmail).toHaveBeenCalled(); + expect(emailService.sendI18nTemplateEmail).toHaveBeenCalled(); expect(result).toStrictEqual({ email: user.emailAddress, uuid: user.uuid }); }); diff --git a/apps/user-service/src/auth/reset-password.service.ts b/apps/user-service/src/auth/reset-password.service.ts index b06c5674..5d7ea978 100644 --- a/apps/user-service/src/auth/reset-password.service.ts +++ b/apps/user-service/src/auth/reset-password.service.ts @@ -3,88 +3,36 @@ import bcrypt from "bcryptjs"; import { JwtService } from "@nestjs/jwt"; import { User } from "@terramatch-microservices/database/entities"; import { EmailService } from "@terramatch-microservices/common/email/email.service"; -import { LocalizationService } from "@terramatch-microservices/common/localization/localization.service"; import { TMLogger } from "@terramatch-microservices/common/util/tm-logger"; -import { TemplateService } from "@terramatch-microservices/common/email/template.service"; + +const EMAIL_KEYS = { + body: "reset-password.body", + subject: "reset-password.subject", + title: "reset-password.title", + cta: "reset-password.cta" +} as const; @Injectable() export class ResetPasswordService { protected readonly logger = new TMLogger(ResetPasswordService.name); - constructor( - private readonly jwtService: JwtService, - private readonly emailService: EmailService, - private readonly localizationService: LocalizationService, - private readonly templateService: TemplateService - ) {} + constructor(private readonly jwtService: JwtService, private readonly emailService: EmailService) {} async sendResetPasswordEmail(emailAddress: string, callbackUrl: string) { - const user = await User.findOne({ where: { emailAddress }, attributes: ["id", "uuid", "locale", "emailAddress"] }); + const user = await User.findOne({ where: { emailAddress }, attributes: ["id", "uuid", "locale"] }); if (user == null) { throw new NotFoundException("User not found"); } - const bodyKey = "reset-password.body"; - const subjectKey = "reset-password.subject"; - const titleKey = "reset-password.title"; - const ctaKey = "reset-password.cta"; - - const localizationKeys = await this.localizationService.getLocalizationKeys([ - bodyKey, - subjectKey, - titleKey, - ctaKey - ]); - - if (!localizationKeys.length) { - throw new NotFoundException("Localizations not found"); - } - - const bodyLocalization = localizationKeys.find(x => x.key == bodyKey); - const subjectLocalization = localizationKeys.find(x => x.key == subjectKey); - const titleLocalization = localizationKeys.find(x => x.key == titleKey); - const ctaLocalization = localizationKeys.find(x => x.key == ctaKey); - - if (bodyLocalization == null) { - throw new NotFoundException("Localization body not found"); - } - - if (subjectLocalization == null) { - throw new NotFoundException("Localization subject not found"); - } - - if (titleLocalization == null) { - throw new NotFoundException("Localization title not found"); - } - - if (ctaLocalization == null) { - throw new NotFoundException("Localization CTA not found"); - } - - const resetToken = await this.jwtService.signAsync({ sub: user.uuid }, { expiresIn: "2h" }); + const { uuid, locale } = user; + const resetToken = await this.jwtService.signAsync({ sub: uuid }, { expiresIn: "2h" }); const resetLink = `${callbackUrl}/${resetToken}`; - const bodyEmail = await this.formatEmail( - user.locale, - bodyLocalization.value, - titleLocalization.value, - ctaLocalization.value, - resetLink - ); - await this.emailService.sendEmail(user.emailAddress, subjectLocalization.value, bodyEmail); - - return { email: user.emailAddress, uuid: user.uuid }; - } + await this.emailService.sendI18nTemplateEmail(emailAddress, locale, EMAIL_KEYS, { + additionalValues: { link: resetLink, monitoring: "monitoring" } + }); - private async formatEmail(locale: string, body: string, title: string, cta: string, callbackUrl: string) { - const emailData = { - title: await this.localizationService.translate(title, locale), - body: await this.localizationService.translate(body, locale), - link: callbackUrl, - cta: await this.localizationService.translate(cta, locale), - monitoring: "monitoring" - }; - return this.templateService.render("user-service/views/default-email.hbs", emailData); + return { email: emailAddress, uuid }; } async resetPassword(resetToken: string, newPassword: string) { diff --git a/apps/user-service/src/health/health.controller.ts b/apps/user-service/src/health/health.controller.ts deleted file mode 100644 index 950e84da..00000000 --- a/apps/user-service/src/health/health.controller.ts +++ /dev/null @@ -1,23 +0,0 @@ -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/user-service/src/health/health.module.ts b/apps/user-service/src/health/health.module.ts deleted file mode 100644 index 0f68be79..00000000 --- a/apps/user-service/src/health/health.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/user-service/src/users/dto/user-update.dto.ts b/apps/user-service/src/users/dto/user-update.dto.ts index d8f95a74..618f5af1 100644 --- a/apps/user-service/src/users/dto/user-update.dto.ts +++ b/apps/user-service/src/users/dto/user-update.dto.ts @@ -1,6 +1,6 @@ -import { Equals, IsEnum, IsUUID, ValidateNested } from "class-validator"; +import { IsEnum } from "class-validator"; import { ApiProperty } from "@nestjs/swagger"; -import { Type } from "class-transformer"; +import { JsonApiBodyDto, JsonApiDataDto } from "@terramatch-microservices/common/util/json-api-update-dto"; const VALID_LOCALES = ["en-US", "es-MX", "fr-FR", "pt-BR"]; @@ -10,24 +10,6 @@ class UserUpdateAttributes { locale?: string | null; } -class UserUpdate { - @Equals("users") - @ApiProperty({ enum: ["users"] }) - type: string; - - @IsUUID() - @ApiProperty({ format: "uuid" }) - id: string; - - @ValidateNested() - @Type(() => UserUpdateAttributes) - @ApiProperty({ type: () => UserUpdateAttributes }) - attributes: UserUpdateAttributes; -} - -export class UserUpdateBodyDto { - @ValidateNested() - @Type(() => UserUpdate) - @ApiProperty({ type: () => UserUpdate }) - data: UserUpdate; -} +export class UserUpdateBody extends JsonApiBodyDto( + class UserData extends JsonApiDataDto({ type: "users" }, UserUpdateAttributes) {} +) {} diff --git a/apps/user-service/src/users/user-creation.service.spec.ts b/apps/user-service/src/users/user-creation.service.spec.ts index 9df02817..a5711f9e 100644 --- a/apps/user-service/src/users/user-creation.service.spec.ts +++ b/apps/user-service/src/users/user-creation.service.spec.ts @@ -1,7 +1,6 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { JwtService } from "@nestjs/jwt"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; -import { ModelHasRole, Role, User, Verification } from "@terramatch-microservices/database/entities"; +import { LocalizationKey, ModelHasRole, Role, User, Verification } from "@terramatch-microservices/database/entities"; import { EmailService } from "@terramatch-microservices/common/email/email.service"; import { LocalizationService } from "@terramatch-microservices/common/localization/localization.service"; import { UserCreationService } from "./user-creation.service"; @@ -9,11 +8,10 @@ import { UserNewRequest } from "./dto/user-new-request.dto"; import { NotFoundException, UnprocessableEntityException } from "@nestjs/common"; import { RoleFactory, UserFactory } from "@terramatch-microservices/database/factories"; import { LocalizationKeyFactory } from "@terramatch-microservices/database/factories/localization-key.factory"; -import { TemplateService } from "@terramatch-microservices/common/email/template.service"; +import { TemplateService } from "@terramatch-microservices/common/templates/template.service"; describe("UserCreationService", () => { let service: UserCreationService; - let jwtService: DeepMocked; let emailService: DeepMocked; let localizationService: DeepMocked; let templateService: DeepMocked; @@ -64,14 +62,14 @@ describe("UserCreationService", () => { return await UserFactory.create(); } + beforeAll(async () => { + await LocalizationKey.truncate(); + }); + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ UserCreationService, - { - provide: JwtService, - useValue: (jwtService = createMock()) - }, { provide: EmailService, useValue: (emailService = createMock()) @@ -116,116 +114,15 @@ describe("UserCreationService", () => { Promise.resolve([localizationBody, localizationSubject, localizationTitle, localizationCta]) ); - emailService.sendEmail.mockReturnValue(Promise.resolve()); + emailService.sendI18nTemplateEmail.mockReturnValue(Promise.resolve()); templateService.render.mockReturnValue("rendered template"); const result = await service.createNewUser(userNewRequest); expect(reloadSpy).toHaveBeenCalled(); - expect(emailService.sendEmail).toHaveBeenCalled(); + expect(emailService.sendI18nTemplateEmail).toHaveBeenCalled(); expect(result).toBeDefined(); }); - it("should return an error because localizations not found", async () => { - const user = await UserFactory.create(); - const userNewRequest = getRequest(user.emailAddress, "project-developer"); - - const role = RoleFactory.create({ name: userNewRequest.role }); - - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(null)); - jest.spyOn(Role, "findOne").mockImplementation(() => Promise.resolve(role)); - jest.spyOn(User, "create").mockImplementation(() => Promise.resolve(user)); - jest.spyOn(ModelHasRole, "findOrCreate").mockResolvedValue(null); - - await expect(service.createNewUser(userNewRequest)).rejects.toThrow( - new NotFoundException("Localizations not found") - ); - }); - - it("should return an error because localization body not found", async () => { - const user = await UserFactory.create(); - const userNewRequest = getRequest(user.emailAddress, "project-developer"); - - const role = RoleFactory.create({ name: userNewRequest.role }); - - const localizationSubject = await getLocalizationSubject(); - - localizationService.getLocalizationKeys.mockReturnValue(Promise.resolve([localizationSubject])); - - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(null)); - jest.spyOn(Role, "findOne").mockImplementation(() => Promise.resolve(role)); - jest.spyOn(User, "create").mockImplementation(() => Promise.resolve(user)); - jest.spyOn(ModelHasRole, "findOrCreate").mockResolvedValue(null); - - await expect(service.createNewUser(userNewRequest)).rejects.toThrow( - new NotFoundException("Localization body not found") - ); - }); - - it("should return an error because localization subject not found", async () => { - const user = await UserFactory.create(); - const userNewRequest = getRequest(user.emailAddress, "project-developer"); - - const role = RoleFactory.create({ name: userNewRequest.role }); - - const localizationBody = await getLocalizationBody(); - - localizationService.getLocalizationKeys.mockReturnValue(Promise.resolve([localizationBody])); - - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(null)); - jest.spyOn(Role, "findOne").mockImplementation(() => Promise.resolve(role)); - jest.spyOn(User, "create").mockImplementation(() => Promise.resolve(user)); - jest.spyOn(ModelHasRole, "findOrCreate").mockResolvedValue(null); - - await expect(service.createNewUser(userNewRequest)).rejects.toThrow( - new NotFoundException("Localization subject not found") - ); - }); - - it("should return an error because localization title not found", async () => { - const user = await UserFactory.create(); - const userNewRequest = getRequest(user.emailAddress, "project-developer"); - - const role = RoleFactory.create({ name: userNewRequest.role }); - - const localizationBody = await getLocalizationBody(); - const localizationSubject = await getLocalizationSubject(); - - localizationService.getLocalizationKeys.mockReturnValue(Promise.resolve([localizationBody, localizationSubject])); - - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(null)); - jest.spyOn(Role, "findOne").mockImplementation(() => Promise.resolve(role)); - jest.spyOn(User, "create").mockImplementation(() => Promise.resolve(user)); - jest.spyOn(ModelHasRole, "findOrCreate").mockResolvedValue(null); - - await expect(service.createNewUser(userNewRequest)).rejects.toThrow( - new NotFoundException("Localization title not found") - ); - }); - - it("should return an error because localization CTA not found", async () => { - const user = await UserFactory.create(); - const userNewRequest = getRequest(user.emailAddress, "project-developer"); - - const role = RoleFactory.create({ name: userNewRequest.role }); - - const localizationBody = await getLocalizationBody(); - const localizationSubject = await getLocalizationSubject(); - const localizationTitle = await getLocalizationTitle(); - - localizationService.getLocalizationKeys.mockReturnValue( - Promise.resolve([localizationBody, localizationSubject, localizationTitle]) - ); - - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(null)); - jest.spyOn(Role, "findOne").mockImplementation(() => Promise.resolve(role)); - jest.spyOn(User, "create").mockImplementation(() => Promise.resolve(user)); - jest.spyOn(ModelHasRole, "findOrCreate").mockResolvedValue(null); - - await expect(service.createNewUser(userNewRequest)).rejects.toThrow( - new NotFoundException("Localization CTA not found") - ); - }); - it("should generate a error because user already exist", async () => { const user = await UserFactory.create(); jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(user)); @@ -241,7 +138,7 @@ describe("UserCreationService", () => { ); await expect(service.createNewUser(userNewRequest)).rejects.toThrow( - new UnprocessableEntityException("User already exist") + new UnprocessableEntityException("User already exists") ); }); @@ -306,8 +203,6 @@ describe("UserCreationService", () => { jest.spyOn(User, "create").mockImplementation(() => Promise.resolve(user)); jest.spyOn(ModelHasRole, "findOrCreate").mockResolvedValue(null); - const token = "fake token"; - jwtService.signAsync.mockReturnValue(Promise.resolve(token)); jest.spyOn(Verification, "findOrCreate").mockImplementation(() => Promise.reject()); const result = await service.createNewUser(userNewRequest); @@ -334,10 +229,7 @@ describe("UserCreationService", () => { Promise.resolve([localizationBody, localizationSubject, localizationTitle, localizationCta]) ); - const token = "fake token"; - jwtService.signAsync.mockReturnValue(Promise.resolve(token)); - - emailService.sendEmail.mockRejectedValue(null); + emailService.sendI18nTemplateEmail.mockRejectedValue(null); const result = await service.createNewUser(userNewRequest); expect(result).toBeNull(); @@ -408,23 +300,8 @@ describe("UserCreationService", () => { jest.spyOn(User, "create").mockImplementation(() => Promise.resolve(user)); jest.spyOn(ModelHasRole, "findOrCreate").mockResolvedValue(null); - const token = "fake token"; - jwtService.signAsync.mockReturnValue(Promise.resolve(token)); jest.spyOn(Verification, "findOrCreate").mockRejectedValue(new Error("Verification creation failed")); await expect(service.createNewUser(userNewRequest)).resolves.toBeNull(); }); - - it("should return an error when localizationService.getLocalizationKeys fails", async () => { - const user = await UserFactory.create(); - const userNewRequest = getRequest(user.emailAddress, "project-developer"); - - jest.spyOn(User, "findOne").mockImplementation(() => Promise.resolve(null)); - jest.spyOn(Role, "findOne").mockImplementation(() => Promise.resolve(null)); - jest - .spyOn(localizationService, "getLocalizationKeys") - .mockRejectedValue(new Error("Localization keys retrieval failed")); - - await expect(service.createNewUser(userNewRequest)).rejects.toThrow("Localization keys retrieval failed"); - }); }); diff --git a/apps/user-service/src/users/user-creation.service.ts b/apps/user-service/src/users/user-creation.service.ts index 5c4c3f62..409e7042 100644 --- a/apps/user-service/src/users/user-creation.service.ts +++ b/apps/user-service/src/users/user-creation.service.ts @@ -1,80 +1,40 @@ import { Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; -import { JwtService } from "@nestjs/jwt"; import { ModelHasRole, Role, User, Verification } from "@terramatch-microservices/database/entities"; import { EmailService } from "@terramatch-microservices/common/email/email.service"; -import { LocalizationService } from "@terramatch-microservices/common/localization/localization.service"; import { UserNewRequest } from "./dto/user-new-request.dto"; import crypto from "node:crypto"; -import { TemplateService } from "@terramatch-microservices/common/email/template.service"; import { omit } from "lodash"; import bcrypt from "bcryptjs"; import { TMLogger } from "@terramatch-microservices/common/util/tm-logger"; +const EMAIL_KEYS = { + body: "user-verification.body", + subjectKey: "user-verification.subject", + titleKey: "user-verification.title", + ctaKey: "user-verification.cta" +} as const; + @Injectable() export class UserCreationService { protected readonly logger = new TMLogger(UserCreationService.name); private roles = ["project-developer", "funder", "government"]; - constructor( - private readonly jwtService: JwtService, - private readonly emailService: EmailService, - private readonly templateService: TemplateService, - private readonly localizationService: LocalizationService - ) {} + constructor(private readonly emailService: EmailService) {} async createNewUser(request: UserNewRequest): Promise { - const bodyKey = "user-verification.body"; - const subjectKey = "user-verification.subject"; - const titleKey = "user-verification.title"; - const ctaKey = "user-verification.cta"; - - const localizationKeys = await this.localizationService.getLocalizationKeys([ - bodyKey, - subjectKey, - titleKey, - ctaKey - ]); - - if (!localizationKeys.length) { - throw new NotFoundException("Localizations not found"); - } - - const bodyLocalization = localizationKeys.find(x => x.key == bodyKey); - const subjectLocalization = localizationKeys.find(x => x.key == subjectKey); - const titleLocalization = localizationKeys.find(x => x.key == titleKey); - const ctaLocalization = localizationKeys.find(x => x.key == ctaKey); - - if (bodyLocalization == null) { - throw new NotFoundException("Localization body not found"); - } - - if (subjectLocalization == null) { - throw new NotFoundException("Localization subject not found"); - } - - if (titleLocalization == null) { - throw new NotFoundException("Localization title not found"); - } - - if (ctaLocalization == null) { - throw new NotFoundException("Localization CTA not found"); - } - const role = request.role; - if (!this.roles.includes(role)) { throw new UnprocessableEntityException("Role not valid"); } const roleEntity = await Role.findOne({ where: { name: role } }); - if (roleEntity == null) { throw new NotFoundException("Role not found"); } const userExists = (await User.count({ where: { emailAddress: request.emailAddress } })) !== 0; if (userExists) { - throw new UnprocessableEntityException("User already exist"); + throw new UnprocessableEntityException("User already exists"); } try { @@ -82,7 +42,7 @@ export class UserCreationService { const callbackUrl = request.callbackUrl; const newUser = omit(request, ["callbackUrl", "role", "password"]); - const user = await User.create({ ...newUser, uuid: crypto.randomUUID(), password: hashPassword }); + const user = await User.create({ ...newUser, password: hashPassword }); await user.reload(); @@ -93,15 +53,7 @@ export class UserCreationService { const token = crypto.randomBytes(32).toString("hex"); await this.saveUserVerification(user.id, token); - await this.sendEmailVerification( - user, - token, - subjectLocalization.value, - bodyLocalization.value, - titleLocalization.value, - ctaLocalization.value, - callbackUrl - ); + await this.sendEmailVerification(user, token, callbackUrl); return user; } catch (error) { this.logger.error(error); @@ -109,42 +61,10 @@ export class UserCreationService { } } - private async formatEmail( - locale: string, - token: string, - body: string, - title: string, - cta: string, - callbackUrl: string - ) { - const emailData = { - title: await this.localizationService.translate(title, locale), - body: await this.localizationService.translate(body, locale), - link: `${callbackUrl}/${token}`, - cta: await this.localizationService.translate(cta, locale), - monitoring: "monitoring" - }; - return this.templateService.render("user-service/views/default-email.hbs", emailData); - } - - private async sendEmailVerification( - user: User, - token: string, - subject: string, - bodyLocalization: string, - titleLocalization: string, - ctaLocalization: string, - callbackUrl: string - ) { - const body = await this.formatEmail( - user.locale, - token, - bodyLocalization, - titleLocalization, - ctaLocalization, - callbackUrl - ); - await this.emailService.sendEmail(user.emailAddress, subject, body); + private async sendEmailVerification({ emailAddress, locale }: User, token: string, callbackUrl: string) { + await this.emailService.sendI18nTemplateEmail(emailAddress, locale, EMAIL_KEYS, { + additionalValues: { link: `${callbackUrl}/${token}`, monitoring: "monitoring" } + }); } private async saveUserVerification(userId: number, token: string) { diff --git a/apps/user-service/src/users/users.controller.ts b/apps/user-service/src/users/users.controller.ts index 82d71eab..4db7a491 100644 --- a/apps/user-service/src/users/users.controller.ts +++ b/apps/user-service/src/users/users.controller.ts @@ -16,7 +16,7 @@ import { ApiOperation, ApiParam } from "@nestjs/swagger"; import { OrganisationDto, UserDto } from "@terramatch-microservices/common/dto"; import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/common/decorators"; import { buildJsonApi, DocumentBuilder, JsonApiDocument } from "@terramatch-microservices/common/util"; -import { UserUpdateBodyDto } from "./dto/user-update.dto"; +import { UserUpdateBody } from "./dto/user-update.dto"; import { NoBearerAuth } from "@terramatch-microservices/common/guards"; import { UserNewRequest } from "./dto/user-new-request.dto"; import { UserCreationService } from "./user-creation.service"; @@ -73,7 +73,7 @@ export class UsersController { @ExceptionResponse(UnauthorizedException, { description: "Authorization failed" }) @ExceptionResponse(NotFoundException, { description: "User with that ID not found" }) @ExceptionResponse(BadRequestException, { description: "Something is malformed about the request" }) - async update(@Param("uuid") uuid: string, @Body() updatePayload: UserUpdateBodyDto) { + async update(@Param("uuid") uuid: string, @Body() updatePayload: UserUpdateBody) { if (uuid !== updatePayload.data.id) { throw new BadRequestException(`Path uuid and payload id do not match`); } diff --git a/apps/user-service/webpack.config.js b/apps/user-service/webpack.config.js index 143e0519..19593752 100644 --- a/apps/user-service/webpack.config.js +++ b/apps/user-service/webpack.config.js @@ -11,7 +11,7 @@ module.exports = { compiler: "tsc", main: "./src/main.ts", tsConfig: "./tsconfig.app.json", - assets: ["./src/assets", "./src/views"], + assets: ["./src/assets"], optimization: false, outputHashing: "none", generatePackageJson: true diff --git a/bin/setup-test-database.sh b/bin/setup-test-database.sh index 59d6c144..795780f1 100755 --- a/bin/setup-test-database.sh +++ b/bin/setup-test-database.sh @@ -1,7 +1,7 @@ #!/bin/bash pushd ../wri-terramatch-api -cat <<- SQL | docker-compose exec -T mariadb mysql -h localhost -u root -proot +cat <<- SQL | docker compose exec -T mariadb mysql -h localhost -u root -proot drop database if exists terramatch_microservices_test; create database terramatch_microservices_test; grant all on terramatch_microservices_test.* to 'wri'@'%'; @@ -9,5 +9,5 @@ SQL popd # Sync the DB schema -nx test database --no-cloud --skip-nx-cache +nx test database --no-cloud --skip-nx-cache libs/database/src/lib/database.module.spec.ts diff --git a/cdk/service-stack/lib/service-stack.ts b/cdk/service-stack/lib/service-stack.ts index 757327a0..e2d01ad5 100644 --- a/cdk/service-stack/lib/service-stack.ts +++ b/cdk/service-stack/lib/service-stack.ts @@ -22,11 +22,20 @@ type Mutable = { -readonly [P in keyof T]: T[P]; }; +// Recommendations for optimal pricing from AWs +const RIGHTSIZE_RECOMMENDATIONS: Record> = { + "research-service": { + prod: { + cpu: 1024, + memoryLimitMiB: 2048 + } + } +}; + const customizeFargate = (service: string, env: string, props: Mutable) => { - if (service === "research-service" && ["prod", "staging"].includes(env)) { - // Beef up the research service in staging and prod - props.cpu = 2048; - props.memoryLimitMiB = 4096; + const recommendation = RIGHTSIZE_RECOMMENDATIONS[service]?.[env]; + if (recommendation != null) { + return { ...props, ...recommendation }; } return props; @@ -82,7 +91,9 @@ export class ServiceStack extends Stack { customizeFargate(service, env, { serviceName: `terramatch-${service}-${env}`, cluster, - cpu: 512, + // These are the recommended defaults by Amazon Rightsize in the billing console + cpu: 256, + memoryLimitMiB: 512, desiredCount: 1, enableExecuteCommand: true, taskImageOptions: { @@ -97,7 +108,6 @@ export class ServiceStack extends Stack { }, securityGroups: securityGroups, taskSubnets: { subnets: privateSubnets }, - memoryLimitMiB: 2048, assignPublicIp: false, publicLoadBalancer: false, loadBalancerName: `${service}-${env}` diff --git a/cdk/service-stack/package-lock.json b/cdk/service-stack/package-lock.json index 1c990253..973800c7 100644 --- a/cdk/service-stack/package-lock.json +++ b/cdk/service-stack/package-lock.json @@ -88,13 +88,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", - "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "dependencies": { - "@babel/highlight": "^7.25.7", - "picocolors": "^1.0.0" + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -224,18 +225,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz", - "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", - "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, "engines": { "node": ">=6.9.0" @@ -251,111 +252,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.7.tgz", - "integrity": "sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==", - "dev": true, - "dependencies": { - "@babel/template": "^7.25.7", - "@babel/types": "^7.25.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", - "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", + "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.25.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/parser": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz", - "integrity": "sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz", + "integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==", "dev": true, "dependencies": { - "@babel/types": "^7.25.8" + "@babel/types": "^7.27.1" }, "bin": { "parser": "bin/babel-parser.js" @@ -587,14 +502,14 @@ } }, "node_modules/@babel/template": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", - "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz", + "integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -619,14 +534,13 @@ } }, "node_modules/@babel/types": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.8.tgz", - "integrity": "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -2014,9 +1928,9 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "dependencies": { "path-key": "^3.1.0", @@ -3574,9 +3488,9 @@ "dev": true }, "node_modules/picocolors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", - "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, "node_modules/picomatch": { @@ -3928,15 +3842,6 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/jest/setup-jest.ts b/jest/setup-jest.ts index 143d620a..7dc1362c 100644 --- a/jest/setup-jest.ts +++ b/jest/setup-jest.ts @@ -1,6 +1,7 @@ import { Sequelize } from "sequelize-typescript"; import { FactoryGirl, SequelizeAdapter } from "factory-girl-ts"; import * as Entities from "@terramatch-microservices/database/entities"; +import { SEQUELIZE_GLOBAL_HOOKS } from "@terramatch-microservices/database/sequelize-config.service"; let sequelize: Sequelize; @@ -14,7 +15,8 @@ beforeAll(async () => { password: "wri", database: "terramatch_microservices_test", models: Object.values(Entities), - // Switch to true locally to debug SQL statements in unit tests, especially table/index creation problems. + hooks: SEQUELIZE_GLOBAL_HOOKS, + // Switch to console.log locally to debug SQL statements in unit tests, especially table/index creation problems. logging: false }); diff --git a/libs/common/src/lib/common.module.ts b/libs/common/src/lib/common.module.ts index 100013bc..9455189b 100644 --- a/libs/common/src/lib/common.module.ts +++ b/libs/common/src/lib/common.module.ts @@ -8,8 +8,15 @@ import { PolicyService } from "./policies/policy.service"; import { LocalizationService } from "./localization/localization.service"; import { EmailService } from "./email/email.service"; import { MediaService } from "./media/media.service"; -import { TemplateService } from "./email/template.service"; import { SlackService } from "./slack/slack.service"; +import { DatabaseModule } from "@terramatch-microservices/database"; +import { EventEmitterModule } from "@nestjs/event-emitter"; +import { EventService } from "./events/event.service"; +import { BullModule } from "@nestjs/bullmq"; +import { EmailProcessor } from "./email/email.processor"; +import { TemplateService } from "./templates/template.service"; + +export const QUEUES = ["email"]; @Module({ imports: [ @@ -23,7 +30,27 @@ import { SlackService } from "./slack/slack.service"; }), ConfigModule.forRoot({ isGlobal: true - }) + }), + DatabaseModule, + // Event Emitter is used for sending lightweight messages about events typically from the DB + // layer to processes that want to hear about specific types of DB updates. + EventEmitterModule.forRoot(), + // BullMQ is used for scheduling tasks that should not happen in the context of a typical API + // request (like sending en email). + BullModule.forRootAsync({ + imports: [ConfigModule.forRoot({ isGlobal: true })], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + connection: { + host: configService.get("REDIS_HOST"), + port: configService.get("REDIS_PORT"), + prefix: "terramatch-microservices", + // Use TLS in AWS + ...(process.env["NODE_ENV"] !== "development" ? { tls: {} } : null) + } + }) + }), + ...QUEUES.map(name => BullModule.registerQueue({ name })) ], providers: [ PolicyService, @@ -32,7 +59,9 @@ import { SlackService } from "./slack/slack.service"; LocalizationService, MediaService, TemplateService, - SlackService + SlackService, + EventService, + EmailProcessor ], exports: [PolicyService, JwtModule, EmailService, LocalizationService, MediaService, TemplateService, SlackService] }) diff --git a/libs/common/src/lib/email/TemplateParams.ts b/libs/common/src/lib/email/TemplateParams.ts deleted file mode 100644 index 18c5c322..00000000 --- a/libs/common/src/lib/email/TemplateParams.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface TemplateParams { - backendUrl?: string; - banner?: string; - year?: number; - monitoring?: string; - invite?: string; - transactional?: string; - unsubscribe?: string; - title: string; - body: string; - link: string; - cta: string; -} diff --git a/libs/common/src/lib/email/email-sender.ts b/libs/common/src/lib/email/email-sender.ts new file mode 100644 index 00000000..fd5ba97d --- /dev/null +++ b/libs/common/src/lib/email/email-sender.ts @@ -0,0 +1,5 @@ +import { EmailService } from "./email.service"; + +export abstract class EmailSender { + abstract send(emailService: EmailService): Promise; +} diff --git a/libs/common/src/lib/email/email.processor.ts b/libs/common/src/lib/email/email.processor.ts new file mode 100644 index 00000000..b64588fb --- /dev/null +++ b/libs/common/src/lib/email/email.processor.ts @@ -0,0 +1,45 @@ +import { OnWorkerEvent, Processor, WorkerHost } from "@nestjs/bullmq"; +import { EmailService } from "./email.service"; +import { Job } from "bullmq"; +import { EntityType } from "@terramatch-microservices/database/constants/entities"; +import { NotImplementedException } from "@nestjs/common"; +import * as Sentry from "@sentry/node"; +import { TMLogger } from "../util/tm-logger"; +import { EntityStatusUpdateEmail } from "./entity-status-update.email"; + +export type StatusUpdateData = { + type: EntityType; + id: number; +}; + +const EMAIL_PROCESSORS = { + statusUpdate: EntityStatusUpdateEmail +}; + +/** + * Watches for jobs related to sending email + */ +@Processor("email") +export class EmailProcessor extends WorkerHost { + private readonly logger = new TMLogger(EmailProcessor.name); + + constructor(private readonly emailService: EmailService) { + super(); + } + + async process(job: Job) { + const { name, data } = job; + const processor = EMAIL_PROCESSORS[name as keyof typeof EMAIL_PROCESSORS]; + if (processor == null) { + throw new NotImplementedException(`Unknown job type: ${name}`); + } + + await new processor(data).send(this.emailService); + } + + @OnWorkerEvent("failed") + async onFailed(job: Job, error: Error) { + Sentry.captureException(error); + this.logger.error(`Worker event failed: ${JSON.stringify(job)}`, error.stack); + } +} diff --git a/libs/common/src/lib/email/email.service.spec.ts b/libs/common/src/lib/email/email.service.spec.ts index fd47a75c..b9933625 100644 --- a/libs/common/src/lib/email/email.service.spec.ts +++ b/libs/common/src/lib/email/email.service.spec.ts @@ -3,6 +3,9 @@ import { EmailService } from "./email.service"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; import { ConfigService } from "@nestjs/config"; import * as nodemailer from "nodemailer"; +import { LocalizationService } from "../localization/localization.service"; +import { UserFactory } from "@terramatch-microservices/database/factories"; +import { TemplateService } from "../templates/template.service"; jest.mock("nodemailer"); @@ -10,13 +13,20 @@ describe("EmailService", () => { let service: EmailService; let configService: DeepMocked; let transporter: nodemailer.Transporter; + let localizationService: DeepMocked; + let templateService: DeepMocked; beforeEach(async () => { // @ts-expect-error mock compiler confusion nodemailer.createTransport.mockReturnValue({ sendMail: jest.fn() }); const module = await Test.createTestingModule({ - providers: [EmailService, { provide: ConfigService, useValue: (configService = createMock()) }] + providers: [ + EmailService, + { provide: ConfigService, useValue: (configService = createMock()) }, + { provide: LocalizationService, useValue: (localizationService = createMock()) }, + { provide: TemplateService, useValue: (templateService = createMock()) } + ] }).compile(); service = module.get(EmailService); @@ -63,4 +73,53 @@ describe("EmailService", () => { }) ); }); + + it("filters email addresses based on ENTITY_UPDATE_DO_NOT_EMAIL in env", async () => { + const u1 = await UserFactory.create(); + const u2 = await UserFactory.create(); + const u3 = await UserFactory.create(); + configService.get.mockImplementation((envName: string) => { + if (envName === "ENTITY_UPDATE_DO_NOT_EMAIL") return `${u1.emailAddress},vader@empire.com`; + return ""; + }); + + const filtered = service.filterEntityEmailRecipients([u1, u2, u3]); + expect(filtered.length).toBe(2); + expect(filtered.map(({ id }) => id)).toEqual([u2, u3].map(({ id }) => id)); + }); + + it("returns the list of users when there is no do not email list", async () => { + const users = await UserFactory.createMany(3); + configService.get.mockImplementation((envName: string) => { + if (envName === "ENTITY_UPDATE_DO_NOT_EMAIL") return ""; + return ""; + }); + expect(service.filterEntityEmailRecipients(users)).toBe(users); + }); + + it("throws if the template subject key is not provided", async () => { + await expect(service.sendI18nTemplateEmail("", "en-GB", {})).rejects.toThrow("Email subject is required"); + }); + + it("translates and renders template, then sends email", async () => { + configService.get.mockImplementation((envName: string) => { + if (envName === "MAIL_FROM_ADDRESS") return "person@terramatch.org"; + if (envName === "MAIL_RECIPIENTS") return ""; + return ""; + }); + + const to = ["pd@wri.org", "monitoring-partner@wri.org"]; + const locale = "es-MX"; + const i18nKeys = { subject: "foo-email.subject", body: "foo-email.body" }; + const i18nReplacements = { "{thing}": "replacement" }; + const additionalValues = { link: "aclu.org" }; + await service.sendI18nTemplateEmail(to, locale, i18nKeys, { + i18nReplacements, + additionalValues + }); + + expect(localizationService.translateKeys).toHaveBeenCalledWith(i18nKeys, locale, i18nReplacements); + expect(templateService.render).toHaveBeenCalled(); + expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ from: "person@terramatch.org", to })); + }); }); diff --git a/libs/common/src/lib/email/email.service.ts b/libs/common/src/lib/email/email.service.ts index e64c2d27..054ca6bc 100644 --- a/libs/common/src/lib/email/email.service.ts +++ b/libs/common/src/lib/email/email.service.ts @@ -1,17 +1,32 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, InternalServerErrorException } from "@nestjs/common"; import * as nodemailer from "nodemailer"; import { ConfigService } from "@nestjs/config"; import * as Mail from "nodemailer/lib/mailer"; +import { Dictionary, isEmpty, isString } from "lodash"; +import { LocalizationService } from "../localization/localization.service"; +import { User } from "@terramatch-microservices/database/entities"; +import { TemplateService } from "../templates/template.service"; + +type I18nEmailOptions = { + i18nReplacements?: Dictionary; + additionalValues?: Dictionary; +}; + +const EMAIL_TEMPLATE = "libs/common/src/lib/views/default-email.hbs"; @Injectable() export class EmailService { private transporter: nodemailer.Transporter; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly localizationService: LocalizationService, + private readonly templateService: TemplateService + ) { this.transporter = nodemailer.createTransport({ host: this.configService.get("MAIL_HOST"), port: this.configService.get("MAIL_PORT"), - secure: this.configService.get("MAIN_PORT") === 465, + secure: this.configService.get("MAIL_PORT") === 465, auth: { user: this.configService.get("MAIL_USERNAME"), pass: this.configService.get("MAIL_PASSWORD") @@ -19,7 +34,28 @@ export class EmailService { }); } - async sendEmail(to: string, subject: string, body: string) { + filterEntityEmailRecipients(recipients: User[]) { + const entityDoNotEmailList = this.configService.get("ENTITY_UPDATE_DO_NOT_EMAIL"); + if (isEmpty(entityDoNotEmailList)) return recipients; + + const doNotEmail = entityDoNotEmailList.split(","); + return recipients.filter(({ emailAddress }) => !doNotEmail.includes(emailAddress)); + } + + async sendI18nTemplateEmail( + to: string | string[], + locale: string, + i18nKeys: Dictionary, + { i18nReplacements, additionalValues }: I18nEmailOptions = {} + ) { + const { subject, body } = await this.renderI18nTemplateEmail(locale, i18nKeys, { + i18nReplacements, + additionalValues + }); + await this.sendEmail(to, subject, body); + } + + async sendEmail(to: string | string[], subject: string, body: string) { const headers = {} as { [p: string]: string | string[] | { prepared: boolean; value: string } }; const mailOptions: Mail.Options = { from: this.configService.get("MAIL_FROM_ADDRESS"), @@ -40,4 +76,47 @@ export class EmailService { await this.transporter.sendMail(mailOptions); } + + private async renderI18nTemplateEmail( + locale: string, + i18nKeys: Dictionary, + { i18nReplacements, additionalValues }: I18nEmailOptions = {} + ) { + if (!this.hasSubject(i18nKeys)) throw new InternalServerErrorException("Email subject is required"); + + const subjectKey = this.getSubjectKey(i18nKeys); + + const { [subjectKey]: subject, ...translated } = await this.localizationService.translateKeys( + i18nKeys, + locale, + i18nReplacements ?? {} + ); + + const data: Dictionary = { + backendUrl: this.configService.get("EMAIL_IMAGE_BASE_URL"), + banner: null, + invite: null, + monitoring: null, + transactional: null, + year: new Date().getFullYear(), + ...translated, + ...(additionalValues ?? {}) + }; + if (isString(data["link"]) && data["link"].substring(0, 4).toLowerCase() !== "http") { + // If we're given a link that's pointing to a raw path, assume it should be prepended with + // the configured app FE base URL. + data["link"] = `${this.configService.get("APP_FRONT_END")}${data["link"]}`; + } + + const body = this.templateService.render(EMAIL_TEMPLATE, data); + return { subject, body }; + } + + private hasSubject(i18nKeys: Dictionary) { + return i18nKeys["subject"] != null || i18nKeys["subjectKey"] != null; + } + + private getSubjectKey(i18nKeys: Dictionary) { + return i18nKeys["subject"] == null ? "subjectKey" : "subject"; + } } diff --git a/libs/common/src/lib/email/entity-status-update.email.ts b/libs/common/src/lib/email/entity-status-update.email.ts new file mode 100644 index 00000000..0d9e8785 --- /dev/null +++ b/libs/common/src/lib/email/entity-status-update.email.ts @@ -0,0 +1,162 @@ +import { EmailSender } from "./email-sender"; +import { EmailService } from "./email.service"; +import { StatusUpdateData } from "./email.processor"; +import { + ENTITY_MODELS, + EntityModel, + EntityType, + getProjectId, + getViewLinkPath, + isReport, + ReportModel +} from "@terramatch-microservices/database/constants/entities"; +import { Dictionary, groupBy, isEmpty } from "lodash"; +import { + ProjectReport, + ProjectUser, + SiteReport, + UpdateRequest, + User +} from "@terramatch-microservices/database/entities"; +import { Op } from "sequelize"; +import { TMLogger } from "../util/tm-logger"; +import { InternalServerErrorException } from "@nestjs/common"; +import { APPROVED, NEEDS_MORE_INFORMATION } from "@terramatch-microservices/database/constants/status"; + +export class EntityStatusUpdateEmail extends EmailSender { + private readonly logger = new TMLogger(EntityStatusUpdateEmail.name); + + private readonly type: EntityType; + private readonly id: number; + + constructor({ type, id }: StatusUpdateData) { + super(); + this.type = type; + this.id = id; + } + + async send(emailService: EmailService) { + const entity = await this.getEntity(); + const status = + entity.status === NEEDS_MORE_INFORMATION || entity.updateRequestStatus === NEEDS_MORE_INFORMATION + ? NEEDS_MORE_INFORMATION + : entity.status; + if (![APPROVED, NEEDS_MORE_INFORMATION].includes(status)) return; + + const logExtras = `[type=${this.type}, id=${this.id}, status=${status}]` as const; + this.logger.log(`Sending status update email ${logExtras}`); + + const to = emailService.filterEntityEmailRecipients(await this.getEntityUsers(entity)); + if (isEmpty(to)) { + this.logger.debug(`No addresses found to send entity update to ${logExtras}`); + return; + } + + const i18nKeys: Dictionary = { + subject: + status === APPROVED + ? "entity-status-change.subject-approved" + : "entity-status-change.subject-needs-more-information", + cta: "entity-status-change.cta" + }; + i18nKeys["title"] = i18nKeys["subject"]; + + if (isReport(entity)) { + i18nKeys["body"] = + status === APPROVED + ? "entity-status-change.body-report-approved" + : "entity-status-change.body-report-needs-more-information"; + } else { + i18nKeys["body"] = + status === APPROVED + ? "entity-status-change.body-entity-approved" + : "entity-status-change.body-entity-needs-more-information"; + } + + const entityTypeName = isReport(entity) ? "Report" : entity.constructor.name; + const feedback = await this.getFeedback(entity); + const i18nReplacements: Dictionary = { + "{entityTypeName}": entityTypeName, + "{lowerEntityTypeName}": entityTypeName.toLowerCase(), + "{entityName}": (isReport(entity) ? "" : entity.name) ?? "", + "{feedback}": feedback == null || feedback === "" ? "(No feedback)" : feedback + }; + if (isReport(entity)) i18nReplacements["{parentEntityName}"] = this.getParentName(entity) ?? ""; + + const additionalValues = { + link: getViewLinkPath(entity), + transactional: "transactional" + }; + + // Group the users by locale and then send the email to each locale group. + await Promise.all( + Object.entries(groupBy(to, "locale")).map(([locale, users]) => + emailService.sendI18nTemplateEmail( + users.map(({ emailAddress }) => emailAddress), + locale, + i18nKeys, + { + i18nReplacements, + additionalValues + } + ) + ) + ); + } + + private getParentName(report: ReportModel) { + if (report instanceof ProjectReport) return report.projectName; + if (report instanceof SiteReport) return report.siteName; + return report.nurseryName; + } + + private async getFeedback(entity: EntityModel) { + if (![APPROVED, NEEDS_MORE_INFORMATION].includes(entity.updateRequestStatus ?? "")) { + return entity.feedback; + } + + const updateRequest = await UpdateRequest.for(entity).findOne({ + order: [["updatedAt", "DESC"]], + attributes: ["feedback"] + }); + return updateRequest?.feedback ?? entity.feedback; + } + + private async getEntity() { + const entityClass = ENTITY_MODELS[this.type]; + if (entityClass == null) { + throw new InternalServerErrorException(`Entity model class not found for entity type [${this.type}]`); + } + + const include = []; + const attributeKeys = Object.keys(entityClass.getAttributes()); + const attributes = ["id", "uuid", "status", "updateRequestStatus", "name", "feedback"].filter(field => + attributeKeys.includes(field) + ); + for (const parentId of ["projectId", "siteId", "nurseryId"]) { + if (attributeKeys.includes(parentId)) { + attributes.push(parentId); + include.push({ association: parentId.substring(0, parentId.length - 2), attributes: ["name"] }); + } + } + const entity = await entityClass.findOne({ where: { id: this.id }, attributes, include }); + if (entity == null) { + throw new InternalServerErrorException(`Entity instance not found for id [type=${this.type}, id=${this.id}]`); + } + + return entity; + } + + private async getEntityUsers(entity: EntityModel) { + const projectId = await getProjectId(entity); + if (projectId == null) { + this.logger.error(`Could not find project ID for entity [type=${entity.constructor.name}, id=${entity.id}]`); + return []; + } + + return await User.findAll({ + where: { id: { [Op.in]: ProjectUser.projectUsersSubquery(projectId) } }, + attributes: ["emailAddress", "locale"] + }); + } +} diff --git a/libs/common/src/lib/email/template.service.ts b/libs/common/src/lib/email/template.service.ts deleted file mode 100644 index 72af2ae4..00000000 --- a/libs/common/src/lib/email/template.service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import * as Handlebars from "handlebars"; -import * as fs from "fs"; -import * as path from "path"; -import { TemplateParams } from "./TemplateParams"; -import { Dictionary } from "factory-girl-ts"; - -@Injectable() -export class TemplateService { - private templates: Dictionary = {}; - - constructor(private readonly configService: ConfigService) {} - - private getCompiledTemplate(template: string) { - if (this.templates[template] != null) return this.templates[template]; - - const templatePath = path.join(__dirname, "..", template); - const templateSource = fs.readFileSync(templatePath, "utf-8"); - return (this.templates[template] = Handlebars.compile(templateSource)); - } - - render(templatePath: string, data: TemplateParams): string { - const params = { - ...data, - backendUrl: this.configService.get("EMAIL_IMAGE_BASE_URL"), - banner: null, - invite: null, - monitoring: null, - transactional: data.transactional || null, - year: new Date().getFullYear() - }; - return this.getCompiledTemplate(templatePath)(params); - } -} diff --git a/libs/common/src/lib/events/entity-status-update.event-processor.spec.ts b/libs/common/src/lib/events/entity-status-update.event-processor.spec.ts new file mode 100644 index 00000000..f268c367 --- /dev/null +++ b/libs/common/src/lib/events/entity-status-update.event-processor.spec.ts @@ -0,0 +1,226 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as FakeTimers from "@sinonjs/fake-timers"; +import { EventService } from "./event.service"; +import { EntityStatusUpdate } from "./entity-status-update.event-processor"; +import { + NurseryReportFactory, + ProjectFactory, + ProjectReportFactory, + SiteReportFactory, + TaskFactory, + UpdateRequestFactory, + UserFactory +} from "@terramatch-microservices/database/factories"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { RequestContext } from "nestjs-request-context"; +import { ActionFactory } from "@terramatch-microservices/database/factories/action.factory"; +import { Action, AuditStatus, Task } from "@terramatch-microservices/database/entities"; +import { faker } from "@faker-js/faker"; +import { DateTime } from "luxon"; +import { ReportModel } from "@terramatch-microservices/database/constants/entities"; +import { Attributes } from "sequelize"; +import { + APPROVED, + AWAITING_APPROVAL, + DUE, + NEEDS_MORE_INFORMATION, + ReportStatus, + STARTED +} from "@terramatch-microservices/database/constants/status"; +import { InternalServerErrorException } from "@nestjs/common"; + +function mockUserId(userId?: number) { + jest + .spyOn(RequestContext, "currentContext", "get") + .mockReturnValue({ req: { authenticatedUserId: userId }, res: {} }); +} + +describe("EntityStatusUpdate EventProcessor", () => { + let eventService: DeepMocked; + + beforeEach(async () => { + eventService = createMock(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should avoid status email and actions for non-entities", async () => { + mockUserId(); + const processor = new EntityStatusUpdate(eventService, await UpdateRequestFactory.forProject.create()); + const statusUpdateSpy = jest.spyOn(processor as any, "sendStatusUpdateEmail"); + const updateActionsSpy = jest.spyOn(processor as any, "updateActions"); + const createAuditStatusSpy = jest.spyOn(processor as any, "createAuditStatus"); + await processor.handle(); + expect(createAuditStatusSpy).toHaveBeenCalled(); + expect(updateActionsSpy).not.toHaveBeenCalled(); + expect(statusUpdateSpy).not.toHaveBeenCalled(); + }); + + it("should send a status update email", async () => { + mockUserId(); + const project = await ProjectFactory.create(); + await new EntityStatusUpdate(eventService, project).handle(); + expect(eventService.emailQueue.add).toHaveBeenCalledWith( + "statusUpdate", + expect.objectContaining({ type: "projects", id: project.id }) + ); + }); + + it("should update actions", async () => { + mockUserId(); + const project = await ProjectFactory.create({ status: APPROVED }); + const action = await ActionFactory.forProject.create({ targetableId: project.id }); + await new EntityStatusUpdate(eventService, project).handle(); + + const actions = await Action.for(project).findAll(); + expect(actions.length).toBe(1); + expect(actions[0].id).not.toBe(action.id); + expect(actions[0]).toMatchObject({ + status: "pending", + type: "notification", + projectId: project.id, + organisationId: project.organisationId, + title: project.name, + text: "Approved" + }); + }); + + it("should create an audit status", async () => { + const user = await UserFactory.create(); + mockUserId(user.id); + + const feedback = faker.lorem.sentence(); + const project = await ProjectFactory.create({ status: APPROVED, feedback }); + await new EntityStatusUpdate(eventService, project).handle(); + + const auditStatus = await AuditStatus.for(project).findOne({ order: [["createdAt", "DESC"]] }); + expect(auditStatus).toMatchObject({ + createdBy: user.emailAddress, + firstName: user.firstName, + lastName: user.lastName, + comment: `Approved: ${feedback}` + }); + }); + + describe("checkTaskStatus", () => { + function createHandler(report: ReportModel) { + const handler = new EntityStatusUpdate(eventService, report); + jest.spyOn(handler as any, "sendStatusUpdateEmail").mockResolvedValue(undefined); + jest.spyOn(handler as any, "updateActions").mockResolvedValue(undefined); + jest.spyOn(handler as any, "createAuditStatus").mockResolvedValue(undefined); + return handler; + } + + async function createOldTask(props?: Partial>) { + const creationTime = DateTime.fromJSDate(new Date()).minus({ minutes: 5 }).set({ millisecond: 0 }).toJSDate(); + const clock = FakeTimers.install({ shouldAdvanceTime: true }); + try { + clock.setSystemTime(creationTime); + return await TaskFactory.create(props); + } finally { + clock.uninstall(); + } + } + + it("should only be called for valid task update statuses", async () => { + const report = await ProjectReportFactory.create(); + const handler = createHandler(report); + const spy = jest.spyOn(handler as any, "checkTaskStatus").mockResolvedValue(undefined); + await handler.handle(); + expect(spy).not.toHaveBeenCalled(); + + async function expectCall(status: ReportStatus, expectCall: boolean) { + spy.mockClear(); + report.status = status; + await handler.handle(); + if (expectCall) expect(spy).toHaveBeenCalledTimes(1); + else expect(spy).not.toHaveBeenCalled(); + } + + await expectCall(STARTED, false); + await expectCall(AWAITING_APPROVAL, true); + await expectCall(NEEDS_MORE_INFORMATION, true); + await expectCall(APPROVED, true); + }); + + it("should log a warning if no task ID is found", async () => { + const projectReport = await ProjectReportFactory.create({ taskId: null, status: AWAITING_APPROVAL }); + const handler = createHandler(projectReport); + const logSpy = jest.spyOn((handler as any).logger, "warn"); + await handler.handle(); + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching("No task found for status changed report")); + }); + + it("should log a warning if the task for the ID is not found", async () => { + const projectReport = await ProjectReportFactory.create({ status: AWAITING_APPROVAL }); + await Task.destroy({ where: { id: projectReport.taskId as number } }); + const handler = createHandler(projectReport); + const logSpy = jest.spyOn((handler as any).logger, "error"); + await handler.handle(); + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching("No task found for task id")); + }); + + it("should NOOP if the task is DUE", async () => { + const task = await createOldTask(); + const projectReport = await ProjectReportFactory.create({ taskId: task.id, status: AWAITING_APPROVAL }); + await createHandler(projectReport).handle(); + await task.reload(); + expect(task.updatedAt).toEqual(task.createdAt); + }); + + it("should move task to approved if all reports are approved", async () => { + const task = await createOldTask({ status: AWAITING_APPROVAL }); + const projectReport = await ProjectReportFactory.create({ taskId: task.id, status: APPROVED }); + await SiteReportFactory.create({ taskId: task.id, status: APPROVED }); + await NurseryReportFactory.create({ taskId: task.id, status: APPROVED }); + await createHandler(projectReport).handle(); + await task.reload(); + expect(task.updatedAt).not.toEqual(task.createdAt); + expect(task.status).toBe(APPROVED); + }); + + it("should throw if there is a report in a bad status", async () => { + const task = await createOldTask({ status: AWAITING_APPROVAL }); + const projectReport = await ProjectReportFactory.create({ taskId: task.id, status: APPROVED }); + const siteReport = await SiteReportFactory.create({ taskId: task.id, status: DUE }); + await expect(createHandler(projectReport).handle()).rejects.toThrow(InternalServerErrorException); + + await siteReport.update({ status: STARTED }); + await expect(createHandler(projectReport).handle()).rejects.toThrow(InternalServerErrorException); + }); + + it("should move to needs-more-information if a report is in that status", async () => { + const task = await createOldTask({ status: AWAITING_APPROVAL }); + const projectReport = await ProjectReportFactory.create({ taskId: task.id, status: APPROVED }); + const siteReport = await SiteReportFactory.create({ taskId: task.id, status: NEEDS_MORE_INFORMATION }); + await createHandler(projectReport).handle(); + await task.reload(); + expect(task.updatedAt).not.toEqual(task.createdAt); + expect(task.status).toBe(NEEDS_MORE_INFORMATION); + + await task.update({ status: AWAITING_APPROVAL }); + await siteReport.update({ status: AWAITING_APPROVAL, updateRequestStatus: NEEDS_MORE_INFORMATION }); + await createHandler(projectReport).handle(); + await task.reload(); + expect(task.updatedAt).not.toEqual(task.createdAt); + expect(task.status).toBe(NEEDS_MORE_INFORMATION); + }); + + it("should move the task to awaiting-approval when all reports are in awaiting-approval", async () => { + const task = await createOldTask({ status: NEEDS_MORE_INFORMATION }); + const projectReport = await ProjectReportFactory.create({ taskId: task.id, status: AWAITING_APPROVAL }); + await SiteReportFactory.create({ taskId: task.id, status: AWAITING_APPROVAL }); + await NurseryReportFactory.create({ + taskId: task.id, + status: NEEDS_MORE_INFORMATION, + updateRequestStatus: AWAITING_APPROVAL + }); + await createHandler(projectReport).handle(); + await task.reload(); + expect(task.updatedAt).not.toEqual(task.createdAt); + expect(task.status).toBe(AWAITING_APPROVAL); + }); + }); +}); diff --git a/libs/common/src/lib/events/entity-status-update.event-processor.ts b/libs/common/src/lib/events/entity-status-update.event-processor.ts new file mode 100644 index 00000000..9530dee8 --- /dev/null +++ b/libs/common/src/lib/events/entity-status-update.event-processor.ts @@ -0,0 +1,201 @@ +import { laravelType, StatusUpdateModel } from "@terramatch-microservices/database/types/util"; +import { EventProcessor } from "./event.processor"; +import { TMLogger } from "../util/tm-logger"; +import { + ENTITY_MODELS, + EntityModel, + EntityType, + getOrganisationId, + getProjectId, + isReport, + ReportModel +} from "@terramatch-microservices/database/constants/entities"; +import { StatusUpdateData } from "../email/email.processor"; +import { EventService } from "./event.service"; +import { Action, AuditStatus, FormQuestion, Task } from "@terramatch-microservices/database/entities"; +import { flatten, get, isEmpty, map, uniq } from "lodash"; +import { Op } from "sequelize"; +import { + APPROVED, + AWAITING_APPROVAL, + DUE, + NEEDS_MORE_INFORMATION, + STARTED, + STATUS_DISPLAY_STRINGS +} from "@terramatch-microservices/database/constants/status"; +import { InternalServerErrorException } from "@nestjs/common"; + +const TASK_UPDATE_REPORT_STATUSES = [APPROVED, NEEDS_MORE_INFORMATION, AWAITING_APPROVAL]; + +export class EntityStatusUpdate extends EventProcessor { + private readonly logger = new TMLogger(EntityStatusUpdate.name); + + constructor(eventService: EventService, private readonly model: StatusUpdateModel) { + super(eventService); + } + + async handle() { + this.logger.log( + `Received model status update [${JSON.stringify({ + type: this.model.constructor.name, + id: this.model.id, + status: this.model.status + })}]` + ); + + const entityType = Object.entries(ENTITY_MODELS).find( + ([, entityClass]) => this.model instanceof entityClass + )?.[0] as EntityType | undefined; + + if (entityType != null) { + await this.sendStatusUpdateEmail(entityType); + await this.updateActions(); + } + await this.createAuditStatus(); + + if ( + entityType != null && + isReport(this.model as EntityModel) && + TASK_UPDATE_REPORT_STATUSES.includes(this.model.status) + ) { + await this.checkTaskStatus(); + } + } + + private async sendStatusUpdateEmail(type: EntityType) { + this.logger.log(`Sending status update to email queue [${JSON.stringify({ type, id: this.model.id })}]`); + await this.eventService.emailQueue.add("statusUpdate", { type, id: this.model.id } as StatusUpdateData); + } + + private async updateActions() { + this.logger.log(`Updating actions [${JSON.stringify({ model: this.model.constructor.name, id: this.model.id })}]`); + const entity = this.model as EntityModel; + await Action.for(entity).destroy({ where: { type: "notification" } }); + + if (entity.status !== "awaiting-approval") { + const action = new Action(); + action.status = "pending"; + action.targetableType = laravelType(entity); + action.targetableId = entity.id; + action.type = "notification"; + action.projectId = (await getProjectId(entity)) ?? null; + action.organisationId = (await getOrganisationId(entity)) ?? null; + + if (!isReport(entity)) { + action.title = get(entity, "name") ?? ""; + action.text = STATUS_DISPLAY_STRINGS[entity.status]; + } + + await action.save(); + } + } + + private async createAuditStatus() { + this.logger.log( + `Creating auditStatus [${JSON.stringify({ model: this.model.constructor.name, id: this.model.id })}]` + ); + + const user = await this.getAuthenticatedUser(); + const auditStatus = new AuditStatus(); + auditStatus.auditableType = laravelType(this.model); + auditStatus.auditableId = this.model.id; + auditStatus.createdBy = user?.emailAddress ?? null; + auditStatus.firstName = user?.firstName ?? null; + auditStatus.lastName = user?.lastName ?? null; + + // TODO: the update is different for UpdateRequest awaiting approval + if (this.model.status === "approved") { + auditStatus.comment = `Approved: ${this.model.feedback}`; + } else if (this.model.status === "restoration-in-progress") { + auditStatus.comment = "Restoration In Progress"; + } else if (this.model.status === "needs-more-information") { + auditStatus.type = "change-request"; + auditStatus.comment = await this.getNeedsMoreInfoComment(); + } else if (this.model.status === "awaiting-approval") { + // NOOP, but avoid the short circuit warning below. + } else { + this.logger.warn( + `Skipping audit status for entity status [${JSON.stringify({ + model: this.model.constructor.name, + id: this.model.id, + status: this.model.status + })}]` + ); + return; + } + + await auditStatus.save(); + } + + private async getNeedsMoreInfoComment() { + const { feedback, feedbackFields } = this.model; + const labels = map( + isEmpty(feedbackFields) + ? [] + : await FormQuestion.findAll({ + where: { uuid: { [Op.in]: feedbackFields as string[] } }, + attributes: ["label"] + }), + "label" + ); + return `Request More Information on the following fields: ${labels.join(", ")}. Feedback: ${ + feedback ?? "(No feedback)" + }`; + } + + private async checkTaskStatus() { + const { taskId } = this.model as ReportModel; + if (taskId == null) { + this.logger.warn(`No task found for status changed report [${this.model.constructor.name}, ${this.model.id}]`); + return; + } + + const attributes = ["id", "status", "updateRequestStatus"]; + const task = await Task.findOne({ + where: { id: taskId }, + include: [ + { association: "projectReport", attributes }, + { association: "siteReports", attributes }, + { association: "nurseryReports", attributes } + ] + }); + if (task == null) { + this.logger.error(`No task found for task id [${taskId}]`); + return; + } + + if (task.status === DUE) { + // No further processing needed; nothing automatic happens until the task has been submitted. + return; + } + + const reports = flatten([task.projectReport, task.siteReports, task.nurseryReports]).filter( + report => report != null + ); + + const reportStatuses = uniq(reports.map(({ status }) => status)); + if (reportStatuses.length === 1 && reportStatuses[0] === APPROVED) { + await task.update({ status: APPROVED }); + return; + } + + if (reportStatuses.includes(DUE) || reportStatuses.includes(STARTED)) { + throw new InternalServerErrorException(`Task has unsubmitted reports [${taskId}]`); + } + + const moreInfoReport = reports.find( + ({ status, updateRequestStatus }) => + (status === NEEDS_MORE_INFORMATION && updateRequestStatus !== AWAITING_APPROVAL) || + updateRequestStatus === NEEDS_MORE_INFORMATION + ); + if (moreInfoReport != null) { + // A report in needs-more-information causes the task to go to needs-more-information + await task.update({ status: NEEDS_MORE_INFORMATION }); + return; + } + + // If there are no reports or update requests in needs-more-information, the only option left is that + // something is in awaiting-approval. + await task.update({ status: AWAITING_APPROVAL }); + } +} diff --git a/libs/common/src/lib/events/event.processor.ts b/libs/common/src/lib/events/event.processor.ts new file mode 100644 index 00000000..14489541 --- /dev/null +++ b/libs/common/src/lib/events/event.processor.ts @@ -0,0 +1,22 @@ +import { User } from "@terramatch-microservices/database/entities"; +import { RequestContext } from "nestjs-request-context"; +import { EventService } from "./event.service"; + +export abstract class EventProcessor { + protected constructor(protected readonly eventService: EventService) {} + + private _authenticatedUser?: User | null; + async getAuthenticatedUser() { + if (this._authenticatedUser != null) return this._authenticatedUser; + + const userId = RequestContext.currentContext.req.authenticatedUserId as number | null; + if (userId == null) return null; + + return (this._authenticatedUser = await User.findOne({ + where: { id: userId }, + attributes: ["id", "emailAddress", "firstName", "lastName"] + })); + } + + abstract handle(): Promise; +} diff --git a/libs/common/src/lib/events/event.service.ts b/libs/common/src/lib/events/event.service.ts new file mode 100644 index 00000000..688ca38d --- /dev/null +++ b/libs/common/src/lib/events/event.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { InjectQueue } from "@nestjs/bullmq"; +import { Queue } from "bullmq"; +import { EntityStatusUpdate } from "./entity-status-update.event-processor"; +import { StatusUpdateModel } from "@terramatch-microservices/database/types/util"; + +/** + * A service to handle general events that are emitted in the common or database libraries, and + * should be handled in all of our various microservice apps. + */ +@Injectable() +export class EventService { + constructor(@InjectQueue("email") readonly emailQueue: Queue) {} + + @OnEvent("database.statusUpdated") + async handleStatusUpdated(model: StatusUpdateModel) { + await new EntityStatusUpdate(this, model).handle(); + } +} diff --git a/apps/unified-database-service/src/health/health.controller.ts b/libs/common/src/lib/health/health.controller.ts similarity index 66% rename from apps/unified-database-service/src/health/health.controller.ts rename to libs/common/src/lib/health/health.controller.ts index 58625d2f..da838c6e 100644 --- a/apps/unified-database-service/src/health/health.controller.ts +++ b/libs/common/src/lib/health/health.controller.ts @@ -1,9 +1,9 @@ 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"; -import { QueueHealthIndicator } from "../airtable/queue-health.indicator"; +import { QueueHealthIndicator } from "./queue-health.indicator"; +import { NoBearerAuth } from "../guards"; @Controller("health") @ApiExcludeController() @@ -18,11 +18,13 @@ export class HealthController { @HealthCheck() @NoBearerAuth async check() { - const connection = await User.sequelize.connectionManager.getConnection({ type: "read" }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sequelize = User.sequelize!; + const connection = await sequelize.connectionManager.getConnection({ type: "read" }); try { return this.health.check([() => this.db.pingCheck("database", { connection }), () => this.queue.isHealthy()]); } finally { - User.sequelize.connectionManager.releaseConnection(connection); + sequelize.connectionManager.releaseConnection(connection); } } } diff --git a/libs/common/src/lib/health/health.module.ts b/libs/common/src/lib/health/health.module.ts new file mode 100644 index 00000000..1641f668 --- /dev/null +++ b/libs/common/src/lib/health/health.module.ts @@ -0,0 +1,29 @@ +import { DynamicModule, Module } from "@nestjs/common"; +import { TerminusModule } from "@nestjs/terminus"; +import { HealthController } from "./health.controller"; +import { QueueHealthIndicator } from "./queue-health.indicator"; +import { QUEUES } from "../common.module"; + +export const QUEUE_LIST = Symbol("QUEUE_LIST"); + +type HealthModuleOptions = { + // Queues to add to what is defined in the common module. + additionalQueues?: string[]; +}; + +@Module({ + imports: [TerminusModule], + providers: [QueueHealthIndicator, { provide: QUEUE_LIST, useValue: QUEUES }], + controllers: [HealthController] +}) +export class HealthModule { + static configure(options: HealthModuleOptions): DynamicModule { + const queueList = [...QUEUES, ...(options.additionalQueues ?? [])]; + return { + module: HealthModule, + imports: [TerminusModule], + providers: [QueueHealthIndicator, { provide: QUEUE_LIST, useValue: queueList }], + controllers: [HealthController] + }; + } +} diff --git a/libs/common/src/lib/health/queue-health.indicator.ts b/libs/common/src/lib/health/queue-health.indicator.ts new file mode 100644 index 00000000..fe781d39 --- /dev/null +++ b/libs/common/src/lib/health/queue-health.indicator.ts @@ -0,0 +1,51 @@ +import { getQueueToken } from "@nestjs/bullmq"; +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { HealthIndicatorService } from "@nestjs/terminus"; +import { Queue } from "bullmq"; +import { ModuleRef } from "@nestjs/core"; +import { Dictionary } from "lodash"; +import { QUEUE_LIST } from "./health.module"; + +type QueueData = { + waitingJobs: number; + totalJobs: number; +}; + +@Injectable() +export class QueueHealthIndicator implements OnModuleInit { + private queues: Queue[] = []; + + constructor(private readonly moduleRef: ModuleRef, private readonly healthIndicatorService: HealthIndicatorService) {} + + async isHealthy() { + const indicator = this.healthIndicatorService.check("redis:bullmq"); + + try { + const data: Dictionary = {}; + for (const queue of this.queues) { + data[queue.name] = await this.checkQueue(queue); + } + + return indicator.up(data); + } catch (error) { + return indicator.down({ message: "Error fetching Airtable queue stats", error }); + } + } + + onModuleInit() { + const queueList = this.moduleRef.get(QUEUE_LIST) as string[]; + for (const name of queueList) { + this.queues.push(this.moduleRef.get(getQueueToken(name), { strict: false })); + } + } + + private async checkQueue(queue: Queue) { + const isHealthy = (await (await queue.client).ping()) === "PONG"; + if (!isHealthy) throw new Error("Redis connection for airtable queue unavailable"); + + return { + waitingJobs: await queue.getWaitingCount(), + totalJobs: await queue.count() + }; + } +} diff --git a/libs/common/src/lib/localization/localization.service.spec.ts b/libs/common/src/lib/localization/localization.service.spec.ts index 0a2a8699..01675652 100644 --- a/libs/common/src/lib/localization/localization.service.spec.ts +++ b/libs/common/src/lib/localization/localization.service.spec.ts @@ -1,9 +1,14 @@ import { Test, TestingModule } from "@nestjs/testing"; import { LocalizationService } from "./localization.service"; -import { i18nItem, i18nTranslation, LocalizationKey } from "@terramatch-microservices/database/entities"; +import { I18nTranslation, LocalizationKey } from "@terramatch-microservices/database/entities"; +import { faker } from "@faker-js/faker"; import { ConfigService } from "@nestjs/config"; -import { tx, t } from "@transifex/native"; +import { normalizeLocale, t, tx } from "@transifex/native"; import { createMock } from "@golevelup/ts-jest"; +import { I18nTranslationFactory } from "@terramatch-microservices/database/factories/i18n-translation.factory"; +import { LocalizationKeyFactory } from "@terramatch-microservices/database/factories/localization-key.factory"; +import { Dictionary } from "lodash"; +import { NotFoundException } from "@nestjs/common"; jest.mock("@transifex/native", () => ({ tx: { @@ -29,51 +34,98 @@ describe("LocalizationService", () => { jest.restoreAllMocks(); }); - it("should return an empty array when no localization keys are found", async () => { - await expect(service.getLocalizationKeys(["foo", "abc"])).resolves.toStrictEqual([]); - }); + describe("getLocalizationKeys", () => { + it("should return an empty array when no localization keys are found", async () => { + await expect(service.getLocalizationKeys(["foo", "abc"])).resolves.toStrictEqual([]); + }); - it("should return one localization key when a matching key is found", async () => { - const localization = [new LocalizationKey()]; - jest.spyOn(LocalizationKey, "findAll").mockImplementation(() => Promise.resolve(localization)); - const result = await service.getLocalizationKeys(["foo"]); - expect(result.length).toBe(1); - }); + it("should return one localization key when a matching key is found", async () => { + const localization = [new LocalizationKey()]; + jest.spyOn(LocalizationKey, "findAll").mockImplementation(() => Promise.resolve(localization)); + const result = await service.getLocalizationKeys(["foo"]); + expect(result.length).toBe(1); + }); - it("should return the translated text when a matching i18n item and translation record are found", async () => { - const i18Record = new i18nTranslation(); - i18Record.shortValue = "contenido traducido"; - const i18Item = new i18nItem(); - jest.spyOn(i18nItem, "findOne").mockImplementation(() => Promise.resolve(i18Item)); - jest.spyOn(i18nTranslation, "findOne").mockImplementation(() => Promise.resolve(i18Record)); - const result = await service.translate("content translate", "es-MX"); - expect(result).toBe(i18Record.shortValue); + it("should return an empty array when none of the keys are found", async () => { + await LocalizationKey.truncate(); + expect(await service.getLocalizationKeys(["foo", "bar"])).toStrictEqual([]); + }); }); - it("should return the original text when no matching i18n item is found", async () => { - jest.spyOn(i18nItem, "findOne").mockImplementation(() => Promise.resolve(null)); - const result = await service.translate("content translate", "es-MX"); - expect(result).toBe("content translate"); - }); + describe("localizeText", () => { + it("should normalize the locale and return the translated text", async () => { + const text = "Hello"; + const locale = "es_MX"; + const translatedText = "Hola"; - it("should return the original text when no translation record is found for the given locale", async () => { - const i18Item = new i18nItem(); - jest.spyOn(i18nItem, "findOne").mockImplementation(() => Promise.resolve(i18Item)); - jest.spyOn(i18nTranslation, "findOne").mockImplementation(() => Promise.resolve(null)); - const result = await service.translate("content translate", "es-MX"); - expect(result).toBe("content translate"); + (normalizeLocale as jest.Mock).mockReturnValue(locale); + (tx.setCurrentLocale as jest.Mock).mockResolvedValue(undefined); + (t as jest.Mock).mockReturnValue(translatedText); + + const result = await service.localizeText(text, "es_ES"); + expect(normalizeLocale).toHaveBeenCalledWith("es_ES"); + expect(tx.setCurrentLocale).toHaveBeenCalledWith(locale); + expect(t).toHaveBeenCalledWith(text, undefined); + expect(result).toBe(translatedText); + }); }); - it("should translate text correctly", async () => { - const text = "Hello"; - const locale = "es_MX"; - const translatedText = "Hola"; + describe("translateKeys", () => { + it("should translate", async () => { + const enTranslations = await I18nTranslationFactory.createMany(5); + const esTranslations = await Promise.all( + enTranslations.map(({ i18nItemId }) => I18nTranslationFactory.create({ i18nItemId, language: "es-MX" })) + ); + const localizationKeys = await Promise.all( + enTranslations.map(({ i18nItemId }) => LocalizationKeyFactory.create({ valueId: i18nItemId })) + ); + + const keyMap = localizationKeys.reduce( + (keyMap, { key }) => ({ ...keyMap, [faker.lorem.slug()]: key as string }), + {} as Dictionary + ); + + const reduceTranslations = (translations: I18nTranslation[]) => + Object.entries(keyMap).reduce((i18nMap, [prop, key]) => { + const itemId = localizationKeys.find(({ key: lKey }) => lKey === key)?.valueId; + if (itemId == null) return i18nMap; + + const translation = translations.find(({ i18nItemId }) => i18nItemId === itemId); + return { + ...i18nMap, + [prop]: translation?.longValue ?? translation?.shortValue ?? "missing-translation" + }; + }, {} as Dictionary); + + expect(await service.translateKeys(keyMap, "en-US")).toMatchObject(reduceTranslations(enTranslations)); + expect(await service.translateKeys(keyMap, "es-MX")).toMatchObject(reduceTranslations(esTranslations)); + }); + + it("uses replacements", async () => { + const esTranslation = await I18nTranslationFactory.create({ + language: "es-MX", + longValue: null, + shortValue: "Este edificio parece uno de {city}, y me encanta {city} y todos los ciudades de {country}." + }); + const { key } = await LocalizationKeyFactory.create({ valueId: esTranslation.i18nItemId }); + expect( + ( + await service.translateKeys({ body: key as string }, "es-MX", { + ["{city}"]: "Barcelona", + ["{country}"]: "España" + }) + )["body"] + ).toBe("Este edificio parece uno de Barcelona, y me encanta Barcelona y todos los ciudades de España."); + }); - (tx.setCurrentLocale as jest.Mock).mockResolvedValue(undefined); - (t as jest.Mock).mockReturnValue(translatedText); + it("should throw if a localization key is missing", async () => { + await LocalizationKey.truncate(); + await expect(service.translateKeys({ foo: "bar" }, "es-MX")).rejects.toThrow(NotFoundException); + }); - const result = await service.localizeText(text, locale); - expect(t).toHaveBeenCalledWith(text, undefined); - expect(result).toBe(translatedText); + it("should use the value from LocalizationKey if there is no translation", async () => { + const { key, value } = await LocalizationKeyFactory.create(); + expect((await service.translateKeys({ foo: key as string }, "es-MX"))["foo"]).toEqual(value); + }); }); }); diff --git a/libs/common/src/lib/localization/localization.service.ts b/libs/common/src/lib/localization/localization.service.ts index fd68c551..3782fcfa 100644 --- a/libs/common/src/lib/localization/localization.service.ts +++ b/libs/common/src/lib/localization/localization.service.ts @@ -1,9 +1,9 @@ -import { Injectable } from "@nestjs/common"; -import { i18nTranslation, LocalizationKey } from "@terramatch-microservices/database/entities"; -import { i18nItem } from "@terramatch-microservices/database/entities/i18n-item.entity"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { I18nTranslation, LocalizationKey } from "@terramatch-microservices/database/entities"; import { Op } from "sequelize"; import { ConfigService } from "@nestjs/config"; import { ITranslateParams, normalizeLocale, tx, t } from "@transifex/native"; +import { Dictionary } from "lodash"; @Injectable() export class LocalizationService { @@ -13,32 +13,33 @@ export class LocalizationService { }); } - async getLocalizationKeys(keys: string[]): Promise { - return await LocalizationKey.findAll({ where: { key: { [Op.in]: keys } } }); - } + async translateKeys(keyMap: Dictionary, locale: string, replacements: Dictionary = {}) { + const keyStrings = Object.values(keyMap); + const keys = await this.getLocalizationKeys(keyStrings); + const i18nItemIds = keys.map(({ valueId }) => valueId).filter(id => id != null); + const translations = + i18nItemIds.length === 0 + ? [] + : await I18nTranslation.findAll({ + where: { i18nItemId: { [Op.in]: i18nItemIds }, language: locale } + }); - private async getItemI18n(value: string): Promise { - return await i18nItem.findOne({ - where: { - [Op.or]: [{ shortValue: value }, { longValue: value }] - } - }); - } + return Object.entries(keyMap).reduce((result, [prop, key]) => { + const { value, valueId } = keys.find(record => record.key === key) ?? {}; + if (value == null) throw new NotFoundException(`Localization key not found [${key}]`); + + const translation = translations.find(({ i18nItemId }) => i18nItemId === valueId); + const translated = Object.entries(replacements).reduce( + (translated, [replacementKey, replacementValue]) => translated.replaceAll(replacementKey, replacementValue), + translation?.longValue ?? translation?.shortValue ?? value + ); - private async getTranslateItem(itemId: number, locale: string): Promise { - return await i18nTranslation.findOne({ where: { i18nItemId: itemId, language: locale } }); + return { ...result, [prop]: translated }; + }, {} as Dictionary); } - async translate(content: string, locale: string): Promise { - const itemResult = await this.getItemI18n(content); - if (itemResult == null) { - return content; - } - const translationResult = await this.getTranslateItem(itemResult.id, locale); - if (translationResult == null) { - return content; - } - return translationResult.shortValue || translationResult.longValue; + async getLocalizationKeys(keys: string[]): Promise { + return await LocalizationKey.findAll({ where: { key: { [Op.in]: keys } } }); } /** diff --git a/libs/common/src/lib/policies/nursery-report.policy.spec.ts b/libs/common/src/lib/policies/nursery-report.policy.spec.ts index 6191009c..a34141dc 100644 --- a/libs/common/src/lib/policies/nursery-report.policy.spec.ts +++ b/libs/common/src/lib/policies/nursery-report.policy.spec.ts @@ -1,6 +1,6 @@ import { PolicyService } from "./policy.service"; import { Test } from "@nestjs/testing"; -import { expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; +import { expectAuthority, expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; import { NurseryReport } from "@terramatch-microservices/database/entities"; import { NurseryFactory, @@ -33,18 +33,18 @@ describe("NurseryReportPolicy", () => { await expectCannot(service, "delete", new NurseryReport()); }); - it("allows reading nursery reports in your framework", async () => { + it("allows managing nursery reports in your framework", async () => { mockUserId(123); mockPermissions("framework-ppc"); const ppc = await NurseryReportFactory.create({ frameworkKey: "ppc" }); const tf = await NurseryReportFactory.create({ frameworkKey: "terrafund" }); - await expectCan(service, "read", ppc); - await expectCannot(service, "read", tf); - await expectCan(service, "delete", ppc); - await expectCannot(service, "delete", tf); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], ppc]], + cannot: [[["read", "delete", "update", "approve"], tf]] + }); }); - it("allows reading nursery reports for own projects", async () => { + it("allows managing nursery reports for own projects", async () => { mockPermissions("manage-own"); const org = await OrganisationFactory.create(); const user = await UserFactory.create({ organisationId: org.id }); @@ -67,17 +67,22 @@ describe("NurseryReportPolicy", () => { const nr3 = await NurseryReportFactory.create({ nurseryId: n3.id }); const nr4 = await NurseryReportFactory.create({ nurseryId: n4.id }); - await expectCan(service, "read", nr1); - await expectCannot(service, "read", nr2); - await expectCan(service, "read", nr3); - await expectCan(service, "read", nr4); - await expectCannot(service, "delete", nr1); - await expectCannot(service, "delete", nr2); - await expectCannot(service, "delete", nr3); - await expectCannot(service, "delete", nr4); + await expectAuthority(service, { + can: [ + [["read", "update"], nr1], + [["read", "update"], nr3], + [["read", "update"], nr4] + ], + cannot: [ + [["delete", "approve"], nr1], + [["read", "delete"], nr2], + ["delete", nr3], + ["delete", nr4] + ] + }); }); - it("allows reading nursery reports for managed projects", async () => { + it("allows managing nursery reports for managed projects", async () => { mockPermissions("projects-manage"); const user = await UserFactory.create(); mockUserId(user.id); @@ -92,9 +97,9 @@ describe("NurseryReportPolicy", () => { const nr1 = await NurseryReportFactory.create({ nurseryId: n1.id }); const nr2 = await NurseryReportFactory.create({ nurseryId: n2.id }); - await expectCan(service, "read", nr1); - await expectCannot(service, "read", nr2); - await expectCan(service, "delete", nr1); - await expectCannot(service, "delete", nr2); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], nr1]], + cannot: [[["read", "delete", "update", "approve"], nr2]] + }); }); }); diff --git a/libs/common/src/lib/policies/nursery-report.policy.ts b/libs/common/src/lib/policies/nursery-report.policy.ts index 3e23b3b8..500b0954 100644 --- a/libs/common/src/lib/policies/nursery-report.policy.ts +++ b/libs/common/src/lib/policies/nursery-report.policy.ts @@ -10,8 +10,9 @@ export class NurseryReportPolicy extends UserPermissionsPolicy { } if (this.frameworks.length > 0) { - this.builder.can("read", NurseryReport, { frameworkKey: { $in: this.frameworks } }); - this.builder.can("delete", NurseryReport, { frameworkKey: { $in: this.frameworks } }); + this.builder.can(["read", "delete", "update", "approve"], NurseryReport, { + frameworkKey: { $in: this.frameworks } + }); } if (this.permissions.includes("manage-own")) { @@ -28,7 +29,7 @@ export class NurseryReportPolicy extends UserPermissionsPolicy { }) ).map(({ id }) => id); if (nurseryIds.length > 0) { - this.builder.can("read", NurseryReport, { nurseryId: { $in: nurseryIds } }); + this.builder.can(["read", "update"], NurseryReport, { nurseryId: { $in: nurseryIds } }); } } } @@ -42,8 +43,9 @@ export class NurseryReportPolicy extends UserPermissionsPolicy { await Nursery.findAll({ where: { projectId: { [Op.in]: projectIds } }, attributes: ["id"] }) ).map(({ id }) => id); if (nurseryIds.length > 0) { - this.builder.can("read", NurseryReport, { nurseryId: { $in: nurseryIds } }); - this.builder.can("delete", NurseryReport, { nurseryId: { $in: nurseryIds } }); + this.builder.can(["read", "delete", "update", "approve"], NurseryReport, { + nurseryId: { $in: nurseryIds } + }); } } } diff --git a/libs/common/src/lib/policies/nursery.policy.spec.ts b/libs/common/src/lib/policies/nursery.policy.spec.ts index 472d0a85..24e358f8 100644 --- a/libs/common/src/lib/policies/nursery.policy.spec.ts +++ b/libs/common/src/lib/policies/nursery.policy.spec.ts @@ -1,6 +1,6 @@ import { PolicyService } from "./policy.service"; import { Test } from "@nestjs/testing"; -import { expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; +import { expectAuthority, expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; import { Nursery } from "@terramatch-microservices/database/entities"; import { NurseryFactory, @@ -38,18 +38,18 @@ describe("NurseryPolicy", () => { await expectCan(service, "read", new Nursery()); }); - it("allows reading nurseries in your framework", async () => { + it("allows managing nurseries in your framework", async () => { mockUserId(123); mockPermissions("framework-ppc"); const ppc = await NurseryFactory.create({ frameworkKey: "ppc" }); const tf = await NurseryFactory.create({ frameworkKey: "terrafund" }); - await expectCan(service, "read", ppc); - await expectCannot(service, "read", tf); - await expectCan(service, "delete", ppc); - await expectCannot(service, "delete", tf); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], ppc]], + cannot: [[["read", "delete", "update", "approve"], tf]] + }); }); - it("allows reading own nurseries", async () => { + it("allows managing own nurseries", async () => { mockPermissions("manage-own"); const org = await OrganisationFactory.create(); const user = await UserFactory.create({ organisationId: org.id }); @@ -68,17 +68,21 @@ describe("NurseryPolicy", () => { const s2 = await NurseryFactory.create({ projectId: p2.id }); const s3 = await NurseryFactory.create({ projectId: p3.id }); const s4 = await NurseryFactory.create({ projectId: p4.id }); - await expectCan(service, "read", s1); - await expectCannot(service, "read", s2); - await expectCan(service, "read", s3); - await expectCan(service, "read", s4); - await expectCan(service, "delete", s1); - await expectCannot(service, "delete", s2); - await expectCan(service, "delete", s3); - await expectCan(service, "delete", s4); + + await expectAuthority(service, { + can: [ + [["read", "delete", "update"], s1], + [["read", "delete", "update"], s3], + [["read", "delete", "update"], s4] + ], + cannot: [ + ["approve", s1], + [["read", "delete", "update"], s2] + ] + }); }); - it("allows reading managed nurseries", async () => { + it("allows managing managed nurseries", async () => { mockPermissions("projects-manage"); const user = await UserFactory.create(); const project = await ProjectFactory.create(); @@ -86,9 +90,9 @@ describe("NurseryPolicy", () => { mockUserId(user.id); const s1 = await NurseryFactory.create({ projectId: project.id }); const s2 = await NurseryFactory.create(); - await expectCan(service, "read", s1); - await expectCannot(service, "read", s2); - await expectCan(service, "delete", s1); - await expectCannot(service, "delete", s2); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], s1]], + cannot: [[["read", "delete", "update", "approve"], s2]] + }); }); }); diff --git a/libs/common/src/lib/policies/nursery.policy.ts b/libs/common/src/lib/policies/nursery.policy.ts index 21c10313..d6c4479a 100644 --- a/libs/common/src/lib/policies/nursery.policy.ts +++ b/libs/common/src/lib/policies/nursery.policy.ts @@ -9,8 +9,7 @@ export class NurseryPolicy extends UserPermissionsPolicy { } if (this.frameworks.length > 0) { - this.builder.can("read", Nursery, { frameworkKey: { $in: this.frameworks } }); - this.builder.can("delete", Nursery, { frameworkKey: { $in: this.frameworks } }); + this.builder.can(["read", "delete", "update", "approve"], Nursery, { frameworkKey: { $in: this.frameworks } }); } if (this.permissions.includes("manage-own")) { @@ -24,8 +23,7 @@ export class NurseryPolicy extends UserPermissionsPolicy { ...user.projects.map(({ id }) => id) ]; if (projectIds.length > 0) { - this.builder.can("read", Nursery, { projectId: { $in: projectIds } }); - this.builder.can("delete", Nursery, { projectId: { $in: projectIds } }); + this.builder.can(["read", "delete", "update"], Nursery, { projectId: { $in: projectIds } }); } } } @@ -35,8 +33,7 @@ export class NurseryPolicy extends UserPermissionsPolicy { if (user != null) { const projectIds = user.projects.filter(({ ProjectUser }) => ProjectUser.isManaging).map(({ id }) => id); if (projectIds.length > 0) { - this.builder.can("read", Nursery, { projectId: { $in: projectIds } }); - this.builder.can("delete", Nursery, { projectId: { $in: projectIds } }); + this.builder.can(["read", "delete", "update", "approve"], Nursery, { projectId: { $in: projectIds } }); } } } diff --git a/libs/common/src/lib/policies/policy.service.spec.ts b/libs/common/src/lib/policies/policy.service.spec.ts index 45a97e5d..84974ffa 100644 --- a/libs/common/src/lib/policies/policy.service.spec.ts +++ b/libs/common/src/lib/policies/policy.service.spec.ts @@ -3,6 +3,7 @@ import { PolicyService } from "./policy.service"; import { UnauthorizedException } from "@nestjs/common"; import { ModelHasRole, Permission, User } from "@terramatch-microservices/database/entities"; import { RequestContext } from "nestjs-request-context"; +import { isArray } from "lodash"; export function mockUserId(userId?: number) { jest @@ -15,12 +16,28 @@ export function mockPermissions(...permissions: string[]) { } type Subject = Parameters[1]; -export async function expectCan(service: PolicyService, action: string, subject: Subject) { - await expect(service.authorize(action, subject)).resolves.toBeUndefined(); +export async function expectCan(service: PolicyService, action: string | string[], subject: Subject) { + const actions = isArray(action) ? action : [action]; + for (const action of actions) { + await expect(service.authorize(action, subject)).resolves.toBeUndefined(); + } } -export async function expectCannot(service: PolicyService, action: string, subject: Subject) { - await expect(service.authorize(action, subject)).rejects.toThrow(UnauthorizedException); +export async function expectCannot(service: PolicyService, action: string | string[], subject: Subject) { + const actions = isArray(action) ? action : [action]; + for (const action of actions) { + await expect(service.authorize(action, subject)).rejects.toThrow(UnauthorizedException); + } +} + +type AuthorityTest = [string | string[], Subject]; +type AuthorityTests = { + can?: AuthorityTest[]; + cannot?: AuthorityTest[]; +}; +export async function expectAuthority(service: PolicyService, tests: AuthorityTests) { + await Promise.all((tests.can ?? []).map(([action, subject]) => expectCan(service, action, subject))); + await Promise.all((tests.cannot ?? []).map(([action, subject]) => expectCannot(service, action, subject))); } describe("PolicyService", () => { diff --git a/libs/common/src/lib/policies/project-report.policy.spec.ts b/libs/common/src/lib/policies/project-report.policy.spec.ts index d8caac5d..79c27477 100644 --- a/libs/common/src/lib/policies/project-report.policy.spec.ts +++ b/libs/common/src/lib/policies/project-report.policy.spec.ts @@ -1,6 +1,6 @@ import { PolicyService } from "./policy.service"; import { Test } from "@nestjs/testing"; -import { expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; +import { expectAuthority, expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; import { ProjectReport } from "@terramatch-microservices/database/entities"; import { OrganisationFactory, @@ -32,18 +32,18 @@ describe("ProjectReportPolicy", () => { await expectCannot(service, "delete", new ProjectReport()); }); - it("allows reading project reports in your framework", async () => { + it("allows managing project reports in your framework", async () => { mockUserId(123); mockPermissions("framework-ppc"); const ppc = await ProjectReportFactory.create({ frameworkKey: "ppc" }); const tf = await ProjectReportFactory.create({ frameworkKey: "terrafund" }); - await expectCan(service, "read", ppc); - await expectCannot(service, "read", tf); - await expectCan(service, "delete", ppc); - await expectCannot(service, "delete", tf); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], ppc]], + cannot: [[["read", "delete", "update", "approve"], tf]] + }); }); - it("allows reading project reports for own projects", async () => { + it("allows managing project reports for own projects", async () => { mockPermissions("manage-own"); const org = await OrganisationFactory.create(); const user = await UserFactory.create({ organisationId: org.id }); @@ -61,17 +61,22 @@ describe("ProjectReportPolicy", () => { const pr3 = await ProjectReportFactory.create({ projectId: p3.id }); const pr4 = await ProjectReportFactory.create({ projectId: p4.id }); - await expectCan(service, "read", pr1); - await expectCannot(service, "read", pr2); - await expectCan(service, "read", pr3); - await expectCan(service, "read", pr4); - await expectCannot(service, "delete", pr1); - await expectCannot(service, "delete", pr2); - await expectCannot(service, "delete", pr3); - await expectCannot(service, "delete", pr4); + await expectAuthority(service, { + can: [ + [["read", "update"], pr1], + [["read", "update"], pr3], + [["read", "update"], pr4] + ], + cannot: [ + [["delete", "approve"], pr1], + [["read", "delete"], pr2], + ["delete", pr3], + ["delete", pr4] + ] + }); }); - it("allows reading project reports for managed projects", async () => { + it("allows managing project reports for managed projects", async () => { mockPermissions("projects-manage"); const user = await UserFactory.create(); mockUserId(user.id); @@ -83,9 +88,9 @@ describe("ProjectReportPolicy", () => { const pr1 = await ProjectReportFactory.create({ projectId: p1.id }); const pr2 = await ProjectReportFactory.create({ projectId: p2.id }); - await expectCan(service, "read", pr1); - await expectCannot(service, "read", pr2); - await expectCan(service, "delete", pr1); - await expectCannot(service, "delete", pr2); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], pr1]], + cannot: [[["read", "delete", "update", "approve"], pr2]] + }); }); }); diff --git a/libs/common/src/lib/policies/project-report.policy.ts b/libs/common/src/lib/policies/project-report.policy.ts index 1f6bad43..4c945731 100644 --- a/libs/common/src/lib/policies/project-report.policy.ts +++ b/libs/common/src/lib/policies/project-report.policy.ts @@ -9,8 +9,9 @@ export class ProjectReportPolicy extends UserPermissionsPolicy { } if (this.frameworks.length > 0) { - this.builder.can("read", ProjectReport, { frameworkKey: { $in: this.frameworks } }); - this.builder.can("delete", ProjectReport, { frameworkKey: { $in: this.frameworks } }); + this.builder.can(["read", "delete", "update", "approve"], ProjectReport, { + frameworkKey: { $in: this.frameworks } + }); } if (this.permissions.includes("manage-own")) { @@ -24,7 +25,7 @@ export class ProjectReportPolicy extends UserPermissionsPolicy { ...user.projects.map(({ id }) => id) ]; if (projectIds.length > 0) { - this.builder.can("read", ProjectReport, { projectId: { $in: projectIds } }); + this.builder.can(["read", "update"], ProjectReport, { projectId: { $in: projectIds } }); } } } @@ -34,8 +35,7 @@ export class ProjectReportPolicy extends UserPermissionsPolicy { if (user != null) { const projectIds = user.projects.filter(({ ProjectUser }) => ProjectUser.isManaging).map(({ id }) => id); if (projectIds.length > 0) { - this.builder.can("read", ProjectReport, { projectId: { $in: projectIds } }); - this.builder.can("delete", ProjectReport, { projectId: { $in: projectIds } }); + this.builder.can(["read", "delete", "update", "approve"], ProjectReport, { projectId: { $in: projectIds } }); } } } diff --git a/libs/common/src/lib/policies/project.policy.spec.ts b/libs/common/src/lib/policies/project.policy.spec.ts index 173b93c1..ac100e42 100644 --- a/libs/common/src/lib/policies/project.policy.spec.ts +++ b/libs/common/src/lib/policies/project.policy.spec.ts @@ -1,6 +1,6 @@ import { PolicyService } from "./policy.service"; import { Test } from "@nestjs/testing"; -import { expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; +import { expectAuthority, expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; import { Project } from "@terramatch-microservices/database/entities"; import { OrganisationFactory, @@ -38,18 +38,18 @@ describe("ProjectPolicy", () => { await expectCannot(service, "delete", new Project()); }); - it("allows reading and deleting projects in your framework", async () => { + it("allows managing projects in your framework", async () => { mockUserId(123); mockPermissions("framework-ppc"); const ppc = await ProjectFactory.create({ frameworkKey: "ppc" }); const tf = await ProjectFactory.create({ frameworkKey: "terrafund" }); - await expectCan(service, "read", ppc); - await expectCan(service, "delete", ppc); - await expectCannot(service, "read", tf); - await expectCannot(service, "delete", tf); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], ppc]], + cannot: [[["read", "delete", "update", "approve"], tf]] + }); }); - it("allows reading and deleting own projects", async () => { + it("allows managing own projects", async () => { mockPermissions("manage-own"); const org = await OrganisationFactory.create(); const user = await UserFactory.create({ organisationId: org.id }); @@ -61,15 +61,20 @@ describe("ProjectPolicy", () => { const p4 = await ProjectFactory.create({ status: "awaiting-approval" }); await ProjectUserFactory.create({ userId: user.id, projectId: p3.id }); await ProjectUserFactory.create({ userId: user.id, projectId: p4.id, isMonitoring: false, isManaging: true }); - await expectCan(service, "read", p1); - await expectCan(service, "delete", p1); - await expectCannot(service, "read", p2); - await expectCannot(service, "delete", p2); - await expectCan(service, "read", p3); - await expectCan(service, "delete", p3); - await expectCan(service, "read", p4); - // This project is not in the "started" state - await expectCannot(service, "delete", p4); + await expectAuthority(service, { + can: [ + [["read", "update", "delete"], p1], + [["read", "update", "delete"], p3], + [["read", "update"], p4] + ], + cannot: [ + // manage-own does not give permission to approve. + ["approve", p1], + [["read", "update", "delete"], p2], + // This project is not in the "started" state + ["delete", p4] + ] + }); }); it("allows reading and deleting managed projects", async () => { @@ -79,9 +84,9 @@ describe("ProjectPolicy", () => { const p1 = await ProjectFactory.create(); const p2 = await ProjectFactory.create(); await ProjectUserFactory.create({ userId: user.id, projectId: p1.id, isMonitoring: false, isManaging: true }); - await expectCan(service, "read", p1); - await expectCan(service, "delete", p1); - await expectCannot(service, "read", p2); - await expectCannot(service, "delete", p2); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], p1]], + cannot: [[["read", "delete", "update", "approve"], p2]] + }); }); }); diff --git a/libs/common/src/lib/policies/project.policy.ts b/libs/common/src/lib/policies/project.policy.ts index 00c4b92b..e3de4028 100644 --- a/libs/common/src/lib/policies/project.policy.ts +++ b/libs/common/src/lib/policies/project.policy.ts @@ -6,22 +6,20 @@ export class ProjectPolicy extends UserPermissionsPolicy { async addRules() { if (this.permissions.includes("projects-read") || this.permissions.includes("view-dashboard")) { this.builder.can("read", Project); - return; } if (this.frameworks.length > 0) { - this.builder.can("read", Project, { frameworkKey: { $in: this.frameworks } }); - this.builder.can("delete", Project, { frameworkKey: { $in: this.frameworks } }); + this.builder.can(["read", "delete", "update", "approve"], Project, { frameworkKey: { $in: this.frameworks } }); } if (this.permissions.includes("manage-own")) { const user = await this.getUser(); if (user != null) { - this.builder.can("read", Project, { organisationId: user.organisationId }); + this.builder.can(["read", "update"], Project, { organisationId: user.organisationId }); this.builder.can("delete", Project, { organisationId: user.organisationId, status: STARTED }); const projectIds = user.projects.map(({ id }) => id); if (projectIds.length > 0) { - this.builder.can("read", Project, { id: { $in: projectIds } }); + this.builder.can(["read", "update"], Project, { id: { $in: projectIds } }); this.builder.can("delete", Project, { id: { $in: projectIds }, status: STARTED }); } } @@ -32,8 +30,7 @@ export class ProjectPolicy extends UserPermissionsPolicy { if (user != null) { const projectIds = user.projects.filter(({ ProjectUser }) => ProjectUser.isManaging).map(({ id }) => id); if (projectIds.length > 0) { - this.builder.can("read", Project, { id: { $in: projectIds } }); - this.builder.can("delete", Project, { id: { $in: projectIds } }); + this.builder.can(["read", "delete", "update", "approve"], Project, { id: { $in: projectIds } }); } } } diff --git a/libs/common/src/lib/policies/site-report.policy.spec.ts b/libs/common/src/lib/policies/site-report.policy.spec.ts index 9cbf91cc..e88a2d1a 100644 --- a/libs/common/src/lib/policies/site-report.policy.spec.ts +++ b/libs/common/src/lib/policies/site-report.policy.spec.ts @@ -1,6 +1,6 @@ import { PolicyService } from "./policy.service"; import { Test } from "@nestjs/testing"; -import { expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; +import { expectAuthority, expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; import { SiteReport } from "@terramatch-microservices/database/entities"; import { OrganisationFactory, @@ -33,18 +33,18 @@ describe("SiteReportPolicy", () => { await expectCannot(service, "delete", new SiteReport()); }); - it("allows reading site reports in your framework", async () => { + it("allows managing site reports in your framework", async () => { mockUserId(123); mockPermissions("framework-ppc"); const ppc = await SiteReportFactory.create({ frameworkKey: "ppc" }); const tf = await SiteReportFactory.create({ frameworkKey: "terrafund" }); - await expectCan(service, "read", ppc); - await expectCannot(service, "read", tf); - await expectCan(service, "delete", ppc); - await expectCannot(service, "delete", tf); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], ppc]], + cannot: [[["read", "delete", "update", "approve"], tf]] + }); }); - it("allows reading site reports for own projects", async () => { + it("allows managing site reports for own projects", async () => { mockPermissions("manage-own"); const org = await OrganisationFactory.create(); const user = await UserFactory.create({ organisationId: org.id }); @@ -67,17 +67,22 @@ describe("SiteReportPolicy", () => { const sr3 = await SiteReportFactory.create({ siteId: s3.id }); const sr4 = await SiteReportFactory.create({ siteId: s4.id }); - await expectCan(service, "read", sr1); - await expectCannot(service, "read", sr2); - await expectCan(service, "read", sr3); - await expectCan(service, "read", sr4); - await expectCannot(service, "delete", sr1); - await expectCannot(service, "delete", sr2); - await expectCannot(service, "delete", sr3); - await expectCannot(service, "delete", sr4); + await expectAuthority(service, { + can: [ + [["read", "update"], sr1], + [["read", "update"], sr3], + [["read", "update"], sr4] + ], + cannot: [ + [["delete", "approve"], sr1], + [["read", "delete"], sr2], + ["delete", sr3], + ["delete", sr4] + ] + }); }); - it("allows reading site reports for managed projects", async () => { + it("allows managing site reports for managed projects", async () => { mockPermissions("projects-manage"); const user = await UserFactory.create(); mockUserId(user.id); @@ -92,9 +97,9 @@ describe("SiteReportPolicy", () => { const sr1 = await SiteReportFactory.create({ siteId: s1.id }); const sr2 = await SiteReportFactory.create({ siteId: s2.id }); - await expectCan(service, "read", sr1); - await expectCannot(service, "read", sr2); - await expectCan(service, "delete", sr1); - await expectCannot(service, "delete", sr2); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], sr1]], + cannot: [[["read", "delete", "update", "approve"], sr2]] + }); }); }); diff --git a/libs/common/src/lib/policies/site-report.policy.ts b/libs/common/src/lib/policies/site-report.policy.ts index 92a23830..e043e914 100644 --- a/libs/common/src/lib/policies/site-report.policy.ts +++ b/libs/common/src/lib/policies/site-report.policy.ts @@ -10,8 +10,7 @@ export class SiteReportPolicy extends UserPermissionsPolicy { } if (this.frameworks.length > 0) { - this.builder.can("read", SiteReport, { frameworkKey: { $in: this.frameworks } }); - this.builder.can("delete", SiteReport, { frameworkKey: { $in: this.frameworks } }); + this.builder.can(["read", "delete", "update", "approve"], SiteReport, { frameworkKey: { $in: this.frameworks } }); } if (this.permissions.includes("manage-own")) { @@ -28,7 +27,7 @@ export class SiteReportPolicy extends UserPermissionsPolicy { }) ).map(({ id }) => id); if (siteIds.length > 0) { - this.builder.can("read", SiteReport, { siteId: { $in: siteIds } }); + this.builder.can(["read", "update"], SiteReport, { siteId: { $in: siteIds } }); } } } @@ -42,8 +41,7 @@ export class SiteReportPolicy extends UserPermissionsPolicy { await Site.findAll({ where: { projectId: { [Op.in]: projectIds } }, attributes: ["id"] }) ).map(({ id }) => id); if (siteIds.length > 0) { - this.builder.can("read", SiteReport, { siteId: { $in: siteIds } }); - this.builder.can("delete", SiteReport, { siteId: { $in: siteIds } }); + this.builder.can(["read", "delete", "update", "approve"], SiteReport, { siteId: { $in: siteIds } }); } } } diff --git a/libs/common/src/lib/policies/site.policy.spec.ts b/libs/common/src/lib/policies/site.policy.spec.ts index d6410a2b..035c2fe5 100644 --- a/libs/common/src/lib/policies/site.policy.spec.ts +++ b/libs/common/src/lib/policies/site.policy.spec.ts @@ -1,6 +1,6 @@ import { PolicyService } from "./policy.service"; import { Test } from "@nestjs/testing"; -import { expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; +import { expectAuthority, expectCan, expectCannot, mockPermissions, mockUserId } from "./policy.service.spec"; import { Site } from "@terramatch-microservices/database/entities"; import { OrganisationFactory, @@ -38,18 +38,18 @@ describe("SitePolicy", () => { await expectCan(service, "read", new Site()); }); - it("allows reading sites in your framework", async () => { + it("allows managing sites in your framework", async () => { mockUserId(123); mockPermissions("framework-ppc"); const ppc = await SiteFactory.create({ frameworkKey: "ppc" }); const tf = await SiteFactory.create({ frameworkKey: "terrafund" }); - await expectCan(service, "read", ppc); - await expectCannot(service, "read", tf); - await expectCan(service, "delete", ppc); - await expectCannot(service, "delete", tf); + await expectAuthority(service, { + can: [[["read", "delete", "update", "approve"], ppc]], + cannot: [[["read", "delete", "update", "approve"], tf]] + }); }); - it("allows reading own sites", async () => { + it("allows managing own sites", async () => { mockPermissions("manage-own"); const org = await OrganisationFactory.create(); const user = await UserFactory.create({ organisationId: org.id }); @@ -68,17 +68,20 @@ describe("SitePolicy", () => { const s2 = await SiteFactory.create({ projectId: p2.id }); const s3 = await SiteFactory.create({ projectId: p3.id }); const s4 = await SiteFactory.create({ projectId: p4.id }); - await expectCan(service, "read", s1); - await expectCannot(service, "read", s2); - await expectCan(service, "read", s3); - await expectCan(service, "read", s4); - await expectCan(service, "delete", s1); - await expectCannot(service, "delete", s2); - await expectCan(service, "delete", s3); - await expectCan(service, "delete", s4); + await expectAuthority(service, { + can: [ + [["read", "delete", "update"], s1], + [["read", "delete", "update"], s3], + [["read", "delete", "update"], s4] + ], + cannot: [ + ["approve", s1], + [["read", "delete"], s2] + ] + }); }); - it("allows reading managed sites", async () => { + it("allows managing managed sites", async () => { mockPermissions("projects-manage"); const user = await UserFactory.create(); const project = await ProjectFactory.create(); @@ -86,9 +89,9 @@ describe("SitePolicy", () => { mockUserId(user.id); const s1 = await SiteFactory.create({ projectId: project.id }); const s2 = await SiteFactory.create(); - await expectCan(service, "read", s1); - await expectCannot(service, "read", s2); - await expectCan(service, "delete", s1); - await expectCannot(service, "delete", s2); + await expectAuthority(service, { + can: [[["read", "delete", "update"], s1]], + cannot: [[["read", "delete", "update"], s2]] + }); }); }); diff --git a/libs/common/src/lib/policies/site.policy.ts b/libs/common/src/lib/policies/site.policy.ts index 4e3af091..dd0c1c53 100644 --- a/libs/common/src/lib/policies/site.policy.ts +++ b/libs/common/src/lib/policies/site.policy.ts @@ -9,8 +9,7 @@ export class SitePolicy extends UserPermissionsPolicy { } if (this.frameworks.length > 0) { - this.builder.can("read", Site, { frameworkKey: { $in: this.frameworks } }); - this.builder.can("delete", Site, { frameworkKey: { $in: this.frameworks } }); + this.builder.can(["read", "delete", "update", "approve"], Site, { frameworkKey: { $in: this.frameworks } }); } if (this.permissions.includes("manage-own")) { @@ -24,8 +23,7 @@ export class SitePolicy extends UserPermissionsPolicy { ...user.projects.map(({ id }) => id) ]; if (projectIds.length > 0) { - this.builder.can("read", Site, { projectId: { $in: projectIds } }); - this.builder.can("delete", Site, { projectId: { $in: projectIds } }); + this.builder.can(["read", "delete", "update"], Site, { projectId: { $in: projectIds } }); } } } @@ -35,8 +33,7 @@ export class SitePolicy extends UserPermissionsPolicy { if (user != null) { const projectIds = user.projects.filter(({ ProjectUser }) => ProjectUser.isManaging).map(({ id }) => id); if (projectIds.length > 0) { - this.builder.can("read", Site, { projectId: { $in: projectIds } }); - this.builder.can("delete", Site, { projectId: { $in: projectIds } }); + this.builder.can(["read", "delete", "update", "approve"], Site, { projectId: { $in: projectIds } }); } } } diff --git a/libs/common/src/lib/templates/template.service.spec.ts b/libs/common/src/lib/templates/template.service.spec.ts new file mode 100644 index 00000000..356b4d58 --- /dev/null +++ b/libs/common/src/lib/templates/template.service.spec.ts @@ -0,0 +1,35 @@ +import * as fs from "fs"; +import { Test } from "@nestjs/testing"; +import { TemplateService } from "./template.service"; + +describe("TemplateService", () => { + let service: TemplateService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [TemplateService] + }).compile(); + service = module.get(TemplateService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should render the template with the given data", async () => { + const template = "A sample {{type}} template. Useful for {{entity}} updates."; + jest.spyOn(fs, "readFileSync").mockImplementation(() => template); + const result = service.render("template", { type: "email", unused: "foo" }); + expect(result).toEqual("A sample email template. Useful for updates."); + }); + + it("should cache the compiled template", async () => { + const template = "A sample {{type}} template. Useful for {{entity}} updates."; + const spy = jest.spyOn(fs, "readFileSync").mockImplementation(() => template); + let result = service.render("template", { type: "email", entity: "foo" }); + expect(result).toEqual("A sample email template. Useful for foo updates."); + result = service.render("template", { type: "text", entity: "bar" }); + expect(result).toEqual("A sample text template. Useful for bar updates."); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/common/src/lib/templates/template.service.ts b/libs/common/src/lib/templates/template.service.ts new file mode 100644 index 00000000..f898d57b --- /dev/null +++ b/libs/common/src/lib/templates/template.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from "@nestjs/common"; +import * as Handlebars from "handlebars"; +import * as fs from "fs"; +import * as path from "path"; +import { Dictionary } from "factory-girl-ts"; + +@Injectable() +export class TemplateService { + private templates: Dictionary = {}; + + private getCompiledTemplate(template: string) { + if (this.templates[template] != null) return this.templates[template]; + + // reference the template path from the NX root. + const templatePath = path.join(__dirname, "../../..", template); + const templateSource = fs.readFileSync(templatePath, "utf-8"); + return (this.templates[template] = Handlebars.compile(templateSource)); + } + + render(templatePath: string, data: Dictionary): string { + return this.getCompiledTemplate(templatePath)(data); + } +} diff --git a/libs/common/src/lib/util/bootstrap-repl.ts b/libs/common/src/lib/util/bootstrap-repl.ts index 272f5df8..2c986a09 100644 --- a/libs/common/src/lib/util/bootstrap-repl.ts +++ b/libs/common/src/lib/util/bootstrap-repl.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { existsSync, mkdirSync } from "node:fs"; import { Dictionary } from "lodash"; import { buildJsonApi } from "./json-api-builder"; +import { Op } from "sequelize"; const logger = new TMLogger("REPL"); @@ -22,7 +23,14 @@ export async function bootstrapRepl(serviceName: string, module: Type | DynamicM const replServer = await repl(module); // By default, we make lodash, the JSON API Builder, and the Sequelize models available - context = { lodash, buildJsonApi, ...replServer.context["get"](Sequelize).models, ...context }; + context = { + lodash, + buildJsonApi, + Op, + Reflect, + ...replServer.context["get"](Sequelize).models, + ...context + }; for (const [name, model] of Object.entries(context as Dictionary)) { // For in REPL auto-complete functionality diff --git a/libs/common/src/lib/util/json-api-update-dto.ts b/libs/common/src/lib/util/json-api-update-dto.ts new file mode 100644 index 00000000..5817d166 --- /dev/null +++ b/libs/common/src/lib/util/json-api-update-dto.ts @@ -0,0 +1,141 @@ +import { DtoOptions } from "../decorators/json-api-dto.decorator"; +import { Equals, IsIn, IsNotEmpty, IsNumberString, IsString, IsUUID, ValidateNested } from "class-validator"; +import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger"; +import { DiscriminatorDescriptor, Type } from "class-transformer"; +import { InternalServerErrorException } from "@nestjs/common"; +import { DECORATORS } from "@nestjs/swagger/dist/constants"; + +function UuidDataDto(type: string, AttributesDto: new () => T) { + class DataDto { + @Equals(type) + @ApiProperty({ enum: [type] }) + type: string; + + @IsUUID() + @ApiProperty({ format: "uuid" }) + id: string; + + @IsNotEmpty() + @ValidateNested() + @Type(() => AttributesDto) + @ApiProperty({ type: () => AttributesDto }) + attributes: T; + } + return DataDto; +} + +function NumberDataDto(type: string, AttributesDto: new () => T) { + class DataDto { + @Equals(type) + @ApiProperty({ enum: [type] }) + type: string; + + @IsNumberString({ no_symbols: true }) + @ApiProperty({ pattern: "^\\d{5}$" }) + id: string; + + @IsNotEmpty() + @ValidateNested() + @Type(() => AttributesDto) + @ApiProperty({ type: () => AttributesDto }) + attributes: T; + } + return DataDto; +} + +function StringDataDto(type: string, AttributesDto: new () => T) { + class DataDto { + @Equals(type) + @ApiProperty({ enum: [type] }) + type: string; + + @IsString() + @ApiProperty() + id: string; + + @IsNotEmpty() + @ValidateNested() + @Type(() => AttributesDto) + @ApiProperty({ type: () => AttributesDto }) + attributes: T; + } + return DataDto; +} + +export function JsonApiDataDto(options: DtoOptions, AttributesDto: new () => T) { + // It's tedious to have these three specified separately, but if we specify these differently as + // an intermediate base class and then a subclass with the correct id annotations, it mixes up + // the order of the properties in the resulting Swagger docs. + if (options.id === "uuid" || options.id == null) { + return UuidDataDto(options.type, AttributesDto); + } + + if (options.id === "number") { + return NumberDataDto(options.type, AttributesDto); + } + + if (options.id === "string") { + return StringDataDto(options.type, AttributesDto); + } + + throw new InternalServerErrorException(`Options id not recognized [${options.id}]`); +} + +export function JsonApiBodyDto(DataDto: new () => T) { + class BodyDto { + @IsNotEmpty() + @ValidateNested() + @Type(() => DataDto) + @ApiProperty({ type: () => DataDto }) + data: T; + } + + return BodyDto; +} + +/** + * Creates a DTO object for JSON:API update that can accept multiple different types of payload + * attributes, distinguished by the `type` param. See `entity-update.dto.ts` for example usage. + * + * Note: the DTOs passed in must be a const array in order to maintain the union type of all possible + * values in T, which is used for the typing of the `data` member of the main body DTO. + */ +export function JsonApiMultiBodyDto unknown>(dtos: readonly T[]) { + const subTypes: DiscriminatorDescriptor["subTypes"] = []; + for (const dto of dtos) { + const apiModelProperties = Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES, dto.prototype, "type"); + if (apiModelProperties == null || apiModelProperties?.enum == null || apiModelProperties?.enum.length !== 1) { + throw new InternalServerErrorException( + "Multi body DTO must have a 'type' property defined with a single enum value" + ); + } + + subTypes.push({ name: apiModelProperties.enum[0], value: dto }); + } + + class GenericData { + @IsIn(subTypes.map(({ name }) => name)) + type: string; + } + + @ApiExtraModels(...dtos) + class MultiBodyDto { + @IsNotEmpty() + @ValidateNested() + @Type(() => GenericData, { + discriminator: { + property: "type", + subTypes + }, + keepDiscriminatorProperty: true + }) + @ApiProperty({ + oneOf: dtos.map(dto => ({ + $ref: getSchemaPath(dto) + })) + }) + data: InstanceType; + } + + return MultiBodyDto; +} diff --git a/apps/user-service/src/views/default-email.hbs b/libs/common/src/lib/views/default-email.hbs similarity index 100% rename from apps/user-service/src/views/default-email.hbs rename to libs/common/src/lib/views/default-email.hbs diff --git a/libs/data-api/.eslintrc.json b/libs/data-api/.eslintrc.json new file mode 100644 index 00000000..9d9c0db5 --- /dev/null +++ b/libs/data-api/.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/libs/data-api/README.md b/libs/data-api/README.md new file mode 100644 index 00000000..12b2ecbd --- /dev/null +++ b/libs/data-api/README.md @@ -0,0 +1,3 @@ +# data-api + +This library was generated with [Nx](https://nx.dev). diff --git a/libs/data-api/jest.config.ts b/libs/data-api/jest.config.ts new file mode 100644 index 00000000..ea293553 --- /dev/null +++ b/libs/data-api/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: "data-api", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }] + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/data-api" +}; diff --git a/libs/data-api/project.json b/libs/data-api/project.json new file mode 100644 index 00000000..36393448 --- /dev/null +++ b/libs/data-api/project.json @@ -0,0 +1,9 @@ +{ + "name": "data-api", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/data-api/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project data-api --web", + "targets": {} +} diff --git a/libs/data-api/src/index.ts b/libs/data-api/src/index.ts new file mode 100644 index 00000000..55c54120 --- /dev/null +++ b/libs/data-api/src/index.ts @@ -0,0 +1,3 @@ +export * from "./lib/data-api.module"; + +export { DataApiService } from "./lib/data-api.service"; diff --git a/libs/data-api/src/lib/data-api.module.ts b/libs/data-api/src/lib/data-api.module.ts new file mode 100644 index 00000000..f85225b3 --- /dev/null +++ b/libs/data-api/src/lib/data-api.module.ts @@ -0,0 +1,24 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { DataApiService } from "./data-api.service"; +import { RedisModule } from "@nestjs-modules/ioredis"; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + RedisModule.forRootAsync({ + imports: [ConfigModule.forRoot({ isGlobal: true })], + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const protocol = process.env["NODE_ENV"] === "development" ? "redis://" : "rediss://"; + return { + type: "single", + url: `${protocol}${configService.get("REDIS_HOST")}:${configService.get("REDIS_PORT")}` + }; + } + }) + ], + providers: [DataApiService], + exports: [DataApiService] +}) +export class DataApiModule {} diff --git a/libs/data-api/src/lib/data-api.service.spec.ts b/libs/data-api/src/lib/data-api.service.spec.ts new file mode 100644 index 00000000..4922246f --- /dev/null +++ b/libs/data-api/src/lib/data-api.service.spec.ts @@ -0,0 +1,90 @@ +import { Test } from "@nestjs/testing"; +import { createMock, DeepMocked, PartialFuncReturn } from "@golevelup/ts-jest"; +import { DataApiService, gadmLevel2 } from "./data-api.service"; +import { ConfigService } from "@nestjs/config"; +import Redis from "ioredis"; +import { getRedisConnectionToken } from "@nestjs-modules/ioredis"; +import fetchMock from "jest-fetch-mock"; +import { InternalServerErrorException } from "@nestjs/common"; + +describe("DataApiService", () => { + let service: DataApiService; + let redis: DeepMocked; + let config: DeepMocked; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + DataApiService, + { + provide: ConfigService, + useValue: (config = createMock({ + get: (key: string): PartialFuncReturn => { + if (key === "APP_FRONT_END") return "https://unittests.terramatch.org"; + if (key === "DATA_API_KEY") return "test-api-key"; + return ""; + } + })) + }, + { + provide: getRedisConnectionToken("default"), + useValue: (redis = createMock({ get: () => Promise.resolve(null) })) + } + ] + }).compile(); + + service = module.get(DataApiService); + fetchMock.enableMocks(); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + fetchMock.resetMocks(); + }); + + it("should check the redis cache first", async () => { + redis.get.mockResolvedValue(JSON.stringify({ cached: "foo" })); + const result = await service.gadmLevel0(); + expect(result).toEqual({ cached: "foo" }); + expect(redis.get).toHaveBeenCalledWith("data-api:gadm-level-0"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("should throw if the environment is not configured correctly", async () => { + config.get.mockReturnValue(null); + await expect(service.gadmLevel0()).rejects.toThrow(InternalServerErrorException); + }); + + it("should throw an error if an error is returned from the data api", async () => { + fetchMock.mockResolvedValue({ status: 404 } as Response); + await expect(service.gadmLevel1("ESP")).rejects.toThrow(InternalServerErrorException); + }); + + it("should return and cache the dataset", async () => { + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + json: () => Promise.resolve({ data: { foo: "mocked data" } }) + } as Response); + const result = await service.gadmLevel2("USA.CA"); + expect(result).toEqual({ foo: "mocked data" }); + + const params = new URLSearchParams(); + params.append("sql", gadmLevel2("USA.CA")); + expect(fetch).toHaveBeenCalledWith( + `https://data-api.globalforestwatch.org/dataset/gadm_administrative_boundaries/v4.1.85/query?${params}`, + expect.objectContaining({ + headers: { + Origin: "unittests.terramatch.org", + "x-api-key": "test-api-key" + } + }) + ); + expect(redis.set).toHaveBeenCalledWith( + "data-api:gadm-level-2:USA.CA", + JSON.stringify({ foo: "mocked data" }), + "EX", + 60 * 60 * 3 + ); + }); +}); diff --git a/libs/data-api/src/lib/data-api.service.ts b/libs/data-api/src/lib/data-api.service.ts new file mode 100644 index 00000000..4646f521 --- /dev/null +++ b/libs/data-api/src/lib/data-api.service.ts @@ -0,0 +1,98 @@ +import { Injectable, InternalServerErrorException } from "@nestjs/common"; +import { TMLogger } from "@terramatch-microservices/common/util/tm-logger"; +import Redis from "ioredis"; +import { InjectRedis } from "@nestjs-modules/ioredis"; +import { ConfigService } from "@nestjs/config"; + +const KEY_NAMESPACE = "data-api:"; + +const DATA_API_DATASET = "https://data-api.globalforestwatch.org/dataset"; +const GADM_QUERY = "/gadm_administrative_boundaries/v4.1.85/query"; +const GADM_CACHE_DURATION = 60 * 60 * 3; // keep GADM definitions in redis for 3 hours. + +const gadmLevel0 = () => ` + SELECT country AS name, gid_0 AS iso + FROM gadm_administrative_boundaries + WHERE adm_level = '0' + AND gid_0 NOT IN ('Z01', 'Z02', 'Z03', 'Z04', 'Z05', 'Z06', 'Z07', 'Z08', 'Z09', 'TWN', 'XCA', 'ESH', 'XSP') +`; + +const gadmLevel1 = (level0: string) => ` + SELECT name_1 AS name, gid_1 AS id + FROM gadm_administrative_boundaries + WHERE adm_level='1' + AND gid_0 = '${level0}' +`; + +// Exported for testing only +export const gadmLevel2 = (level1: string) => ` + SELECT gid_2 as id, name_2 as name + FROM gadm_administrative_boundaries + WHERE gid_1 = '${level1}' + AND adm_level='2' + AND type_2 NOT IN ('Waterbody', 'Water body', 'Water Body') +`; + +type GadmCountry = { + name: string; + iso: string; +}; + +type GadmLevelCode = { + name: string; + id: string; +}; + +/** + * A service for accessing, and in some cases caching, data from the Data API. It's not super + * clear how this service will expand in the future, so for now it's not trying to be too + * clever about how it presents the APIs that it supports. + */ +@Injectable() +export class DataApiService { + private readonly logger = new TMLogger(DataApiService.name); + + constructor(@InjectRedis() private readonly redis: Redis, private readonly configService: ConfigService) {} + + async gadmLevel0(): Promise { + return await this.getDataset("gadm-level-0", GADM_QUERY, gadmLevel0(), GADM_CACHE_DURATION); + } + + async gadmLevel1(level0: string): Promise { + return await this.getDataset(`gadm-level-1:${level0}`, GADM_QUERY, gadmLevel1(level0), GADM_CACHE_DURATION); + } + + async gadmLevel2(level1: string): Promise { + return await this.getDataset(`gadm-level-2:${level1}`, GADM_QUERY, gadmLevel2(level1), GADM_CACHE_DURATION); + } + + private async getDataset(key: string, queryPath: string, query: string, cacheDuration: number) { + const current = await this.redis.get(`${KEY_NAMESPACE}${key}`); + if (current != null) return JSON.parse(current); + + const appFrontend = this.configService.get("APP_FRONT_END"); + const dataApiKey = this.configService.get("DATA_API_KEY"); + if (appFrontend == null || dataApiKey == null) { + throw new InternalServerErrorException("APP_FRONT_END and DATA_API_KEY are required"); + } + + this.logger.log(`Cache miss, running query for ${key}`); + const params = new URLSearchParams(); + params.append("sql", query); + const response = await fetch(`${DATA_API_DATASET}${queryPath}?${params}`, { + headers: { + Origin: new URL(appFrontend).hostname, + "x-api-key": dataApiKey + } + }); + + if (response.status !== 200) { + throw new InternalServerErrorException(response.statusText); + } + + const json = (await response.json()) as { data: never }; + await this.redis.set(`${KEY_NAMESPACE}${key}`, JSON.stringify(json.data), "EX", cacheDuration); + + return json.data; + } +} diff --git a/libs/data-api/tsconfig.json b/libs/data-api/tsconfig.json new file mode 100644 index 00000000..84c538bf --- /dev/null +++ b/libs/data-api/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "strictPropertyInitialization": false, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/data-api/tsconfig.lib.json b/libs/data-api/tsconfig.lib.json new file mode 100644 index 00000000..c297a248 --- /dev/null +++ b/libs/data-api/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/data-api/tsconfig.spec.json b/libs/data-api/tsconfig.spec.json new file mode 100644 index 00000000..f6d8ffcc --- /dev/null +++ b/libs/data-api/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/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/database/src/lib/constants/entities.ts b/libs/database/src/lib/constants/entities.ts index 38326b06..97f92b30 100644 --- a/libs/database/src/lib/constants/entities.ts +++ b/libs/database/src/lib/constants/entities.ts @@ -1,6 +1,7 @@ import { Nursery, NurseryReport, Project, ProjectReport, Site, SiteReport } from "../entities"; import { ModelCtor } from "sequelize-typescript"; import { ModelStatic } from "sequelize"; +import { kebabCase } from "lodash"; export const REPORT_TYPES = ["projectReports", "siteReports", "nurseryReports"] as const; export type ReportType = (typeof REPORT_TYPES)[number]; @@ -24,3 +25,34 @@ export const ENTITY_MODELS: { [E in EntityType]: EntityClass } = { sites: Site, nurseries: Nursery }; + +export const isReport = (entity: EntityModel): entity is ReportModel => + Object.values(REPORT_MODELS).find(model => entity instanceof model) != null; + +/** + * Get the project ID associated with the given entity, which may be any one of EntityModels defined in this file. + * + * Note: this method does require that for sites, nurseries and project reports, the entity's projectId must have + * been loaded when it was fetched from the DB, or `undefined` will be returned. Likewise, For site reports and + * nursery reports, the associated parent entity's id must be included. + */ +export async function getProjectId(entity: EntityModel) { + if (entity instanceof Project) return entity.id; + if (entity instanceof Site || entity instanceof Nursery || entity instanceof ProjectReport) return entity.projectId; + + const parentClass: ModelCtor = entity instanceof SiteReport ? Site : Nursery; + const parentId = entity instanceof SiteReport ? entity.siteId : entity.nurseryId; + return (await parentClass.findOne({ where: { id: parentId }, attributes: ["projectId"] }))?.projectId; +} + +export async function getOrganisationId(entity: EntityModel) { + if (entity instanceof Project) return entity.organisationId; + + return (await Project.findOne({ where: { id: await getProjectId(entity) }, attributes: ["organisationId"] })) + ?.organisationId; +} + +export function getViewLinkPath(entity: EntityModel) { + const prefix = isReport(entity) ? "/reports/" : "/"; + return `${prefix}${kebabCase(entity.constructor.name)}/${entity.uuid}`; +} diff --git a/libs/database/src/lib/constants/status.ts b/libs/database/src/lib/constants/status.ts index 45bff790..5c9bb5c2 100644 --- a/libs/database/src/lib/constants/status.ts +++ b/libs/database/src/lib/constants/status.ts @@ -1,19 +1,89 @@ +import { States, transitions } from "../util/model-column-state-machine"; +import { Nursery, Project, ProjectReport, Site, Task } from "../entities"; +import { Model } from "sequelize-typescript"; +import { DatabaseModule } from "../database.module"; +import { ReportModel } from "./entities"; + export const STARTED = "started"; export const AWAITING_APPROVAL = "awaiting-approval"; export const APPROVED = "approved"; export const RESTORATION_IN_PROGRESS = "restoration-in-progress"; export const NEEDS_MORE_INFORMATION = "needs-more-information"; export const ENTITY_STATUSES = [STARTED, AWAITING_APPROVAL, APPROVED, NEEDS_MORE_INFORMATION] as const; -export const SITE_STATUSES = [...ENTITY_STATUSES, RESTORATION_IN_PROGRESS] as const; export type EntityStatus = (typeof ENTITY_STATUSES)[number]; + +const emitStatusUpdateHook = (from: string, model: Model) => { + DatabaseModule.emitModelEvent("statusUpdated", model); +}; + +export const EntityStatusStates: States = { + default: STARTED, + + transitions: transitions() + .from(STARTED, () => [AWAITING_APPROVAL]) + .from(AWAITING_APPROVAL, () => [APPROVED, NEEDS_MORE_INFORMATION]) + .from(NEEDS_MORE_INFORMATION, () => [APPROVED, AWAITING_APPROVAL]) + .from(APPROVED, () => [NEEDS_MORE_INFORMATION]).transitions, + + afterTransitionHooks: { + [APPROVED]: emitStatusUpdateHook, + [AWAITING_APPROVAL]: emitStatusUpdateHook, + [NEEDS_MORE_INFORMATION]: emitStatusUpdateHook + } +}; + +export const SITE_STATUSES = [...ENTITY_STATUSES, RESTORATION_IN_PROGRESS] as const; export type SiteStatus = (typeof SITE_STATUSES)[number]; +export const SiteStatusStates: States = { + ...(EntityStatusStates as unknown as States), + + transitions: transitions(EntityStatusStates.transitions) + .from(AWAITING_APPROVAL, to => [...to, RESTORATION_IN_PROGRESS]) + .from(NEEDS_MORE_INFORMATION, to => [...to, RESTORATION_IN_PROGRESS]) + .from(APPROVED, to => [...to, RESTORATION_IN_PROGRESS]) + .from(RESTORATION_IN_PROGRESS, () => [NEEDS_MORE_INFORMATION, APPROVED]).transitions +}; + export const DUE = "due"; export const REPORT_STATUSES = [DUE, ...ENTITY_STATUSES] as const; export type ReportStatus = (typeof REPORT_STATUSES)[number]; +export const ReportStatusStates: States = { + ...(EntityStatusStates as unknown as States), + + default: DUE, + + transitions: transitions(EntityStatusStates.transitions) + .from(DUE, () => [STARTED, AWAITING_APPROVAL]) + // reports can go from awaiting approval to started in the nothing_to_report case (see validation below) + .from(AWAITING_APPROVAL, to => [...to, STARTED]).transitions, + + transitionValidForModel: (from: ReportStatus, to: ReportStatus, report: ReportModel) => { + if ((from === DUE && to === AWAITING_APPROVAL) || (from === AWAITING_APPROVAL && to === STARTED)) { + // these two transitions are only allowed for site / nursery reports when the nothingToReport flag is true; + return !(report instanceof ProjectReport) && report.nothingToReport === true; + } + + return true; + } +}; + export const COMPLETE_REPORT_STATUSES = [APPROVED, AWAITING_APPROVAL] as const; +export const TASK_STATUSES = [DUE, NEEDS_MORE_INFORMATION, AWAITING_APPROVAL, APPROVED] as const; +export type TaskStatus = (typeof TASK_STATUSES)[number]; + +export const TaskStatusStates: States = { + default: DUE, + + transitions: transitions() + .from(DUE, () => [AWAITING_APPROVAL]) + .from(AWAITING_APPROVAL, () => [NEEDS_MORE_INFORMATION, APPROVED]) + .from(NEEDS_MORE_INFORMATION, () => [AWAITING_APPROVAL, APPROVED]) + .from(APPROVED, () => [AWAITING_APPROVAL, NEEDS_MORE_INFORMATION]).transitions +}; + export const DRAFT = "draft"; export const NO_UPDATE = "no-update"; export const UPDATE_REQUEST_STATUSES = [NO_UPDATE, DRAFT, AWAITING_APPROVAL, APPROVED, NEEDS_MORE_INFORMATION] as const; @@ -31,5 +101,37 @@ export const FORM_SUBMISSION_STATUSES = [ export type FormSubmissionStatus = (typeof FORM_SUBMISSION_STATUSES)[number]; export const PENDING = "pending"; -export const ORGANISATION_STATUSES = [APPROVED, PENDING, REJECTED, DRAFT]; +export const ORGANISATION_STATUSES = [APPROVED, PENDING, REJECTED, DRAFT] as const; export type OrganisationStatus = (typeof ORGANISATION_STATUSES)[number]; + +type AllStatuses = + | EntityStatus + | SiteStatus + | ReportStatus + | UpdateRequestStatus + | FormSubmissionStatus + | OrganisationStatus; + +/** + * A mapping of all statuses to an English language display string for that status. + * + * Note: Please do not send this value to the client directly. The client should be responsible + * for managing (and translating) these display strings itself. This is used to support some legacy + * systems (like Actions) that require a display string for a status to be embedded in the DB. + * + * Ideally we fix up and remove those needs over time, and eventually git rid of this structure from + * BE code. + */ +export const STATUS_DISPLAY_STRINGS: Record = { + [DRAFT]: "Draft", + [DUE]: "Due", + [PENDING]: "Pending", + [STARTED]: "Started", + [AWAITING_APPROVAL]: "Awaiting approval", + [NEEDS_MORE_INFORMATION]: "Needs more information", + [APPROVED]: "Approved", + [RESTORATION_IN_PROGRESS]: "Restoration in progress", + [REJECTED]: "Rejected", + [NO_UPDATE]: "No update", + [REQUIRES_MORE_INFORMATION]: "Requires more information" +}; diff --git a/libs/database/src/lib/database.module.ts b/libs/database/src/lib/database.module.ts index db3c6d53..29629b4c 100644 --- a/libs/database/src/lib/database.module.ts +++ b/libs/database/src/lib/database.module.ts @@ -2,6 +2,8 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { SequelizeModule } from "@nestjs/sequelize"; import { SequelizeConfigService } from "./sequelize-config.service"; +import { EventEmitter2, EventEmitterModule } from "@nestjs/event-emitter"; +import { Model } from "sequelize-typescript"; @Module({ imports: [ @@ -9,7 +11,24 @@ import { SequelizeConfigService } from "./sequelize-config.service"; SequelizeModule.forRootAsync({ useClass: SequelizeConfigService, imports: [ConfigModule.forRoot({ isGlobal: true })] - }) + }), + EventEmitterModule.forRoot() ] }) -export class DatabaseModule {} +export class DatabaseModule { + /** + * This is made statically available _only_ for use in the database model classes and their + * direct dependencies. Doing it this way is a bit of an anti-pattern, but our DB layer does + * not support NestJS dependency injection in a way that would give access to this event emitter + * in model code. + */ + private static eventEmitter?: EventEmitter2; + + constructor(readonly eventEmitter: EventEmitter2) { + DatabaseModule.eventEmitter = eventEmitter; + } + + static emitModelEvent(eventName: string, model: Model) { + this.eventEmitter?.emit(`database.${eventName}`, model); + } +} diff --git a/libs/database/src/lib/entities/action.entity.ts b/libs/database/src/lib/entities/action.entity.ts index 62507be6..e643c893 100644 --- a/libs/database/src/lib/entities/action.entity.ts +++ b/libs/database/src/lib/entities/action.entity.ts @@ -10,18 +10,17 @@ import { Table, Unique } from "sequelize-typescript"; -import { BIGINT, STRING, UUID } from "sequelize"; +import { BIGINT, STRING, UUID, UUIDV4 } from "sequelize"; import { Organisation } from "./organisation.entity"; import { Project } from "./project.entity"; -import { Literal } from "sequelize/types/utils"; -import { isNumber } from "lodash"; import { chainScope } from "../util/chain-scope"; +import { LaravelModel, laravelType } from "../types/util"; @Scopes(() => ({ - targetable: (laravelType: string, ids: number[] | Literal) => ({ + targetable: (targetable: LaravelModel) => ({ where: { - targetableType: laravelType, - targetableId: ids + targetableType: laravelType(targetable), + targetableId: targetable.id } }) })) @@ -33,9 +32,8 @@ import { chainScope } from "../util/chain-scope"; indexes: [{ name: "v2_actions_targetable_type_targetable_id_index", fields: ["targetable_type", "targetable_id"] }] }) export class Action extends Model { - static targetable(laravelType: string, ids: number | number[] | Literal) { - if (isNumber(ids)) ids = [ids]; - return chainScope(this, "targetable", laravelType, ids) as typeof Action; + static for(targetable: LaravelModel) { + return chainScope(this, "targetable", targetable) as typeof Action; } @PrimaryKey @@ -44,7 +42,7 @@ export class Action extends Model { override id: number; @Unique - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull @@ -57,12 +55,13 @@ export class Action extends Model { @Column(BIGINT.UNSIGNED) targetableId: number; + @AllowNull @ForeignKey(() => Organisation) @Column(BIGINT.UNSIGNED) - organisationId: number; + organisationId: number | null; @BelongsTo(() => Organisation) - organisation?: Organisation; + organisation?: Organisation | null; @AllowNull @ForeignKey(() => Project) diff --git a/libs/database/src/lib/entities/application.entity.ts b/libs/database/src/lib/entities/application.entity.ts index 4889c211..43489ad6 100644 --- a/libs/database/src/lib/entities/application.entity.ts +++ b/libs/database/src/lib/entities/application.entity.ts @@ -10,7 +10,7 @@ import { Table, Unique } from "sequelize-typescript"; -import { BIGINT, UUID } from "sequelize"; +import { BIGINT, UUID, UUIDV4 } from "sequelize"; import { User } from "./user.entity"; import { FormSubmission } from "./form-submission.entity"; import { FundingProgramme } from "./funding-programme.entity"; @@ -30,7 +30,7 @@ export class Application extends Model { override id: number; @Unique - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull diff --git a/libs/database/src/lib/entities/audit-status.entity.ts b/libs/database/src/lib/entities/audit-status.entity.ts new file mode 100644 index 00000000..06494601 --- /dev/null +++ b/libs/database/src/lib/entities/audit-status.entity.ts @@ -0,0 +1,104 @@ +import { AllowNull, AutoIncrement, Column, Index, Model, PrimaryKey, Scopes, Table } from "sequelize-typescript"; +import { BIGINT, BOOLEAN, DATE, ENUM, NOW, STRING, TEXT, UUID, UUIDV4 } from "sequelize"; +import { LaravelModel, laravelType } from "../types/util"; +import { chainScope } from "../util/chain-scope"; + +const TYPES = ["change-request", "status", "submission", "comment", "change-request-updated", "reminder-sent"] as const; +type AuditStatusType = (typeof TYPES)[number]; + +@Scopes(() => ({ + auditable: (auditable: LaravelModel) => ({ + where: { + auditableType: laravelType(auditable), + auditableId: auditable.id + } + }) +})) +@Table({ + tableName: "audit_statuses", + underscored: true, + paranoid: true, + // @Index doesn't work with underscored column names in all contexts + indexes: [{ name: "audit_statuses_auditable_type_auditable_id_index", fields: ["auditable_type", "auditable_id"] }] +}) +export class AuditStatus extends Model { + static for(auditable: LaravelModel) { + return chainScope(this, "auditable", auditable) as typeof AuditStatus; + } + + @PrimaryKey + @AutoIncrement + @Column(BIGINT.UNSIGNED) + override id: number; + + @Index + @Column({ type: UUID, defaultValue: UUIDV4 }) + uuid: string; + + @AllowNull + @Column(STRING) + status: string | null; + + @AllowNull + @Column(TEXT) + comment: string | null; + + @AllowNull + @Column(STRING) + firstName: string | null; + + @AllowNull + @Column(STRING) + lastName: string | null; + + @AllowNull + @Column({ type: ENUM, values: TYPES }) + type: AuditStatusType | null; + + /** + * @deprecated + * + * All records in the DB have null for this field, so it seems not to be useful. + */ + @AllowNull + @Column(BOOLEAN) + isSubmitted: boolean | null; + + /** + * @deprecated + * + * All records in the DB have true for this field, so it seems not to be useful. + */ + @AllowNull + @Column({ type: BOOLEAN, defaultValue: true }) + isActive: boolean | null; + + /** + * @deprecated + * + * All records in the DB have null for this field, so it seems not to be useful. + */ + @AllowNull + @Column(BOOLEAN) + requestRemoved: boolean | null; + + /** + * @deprecated + * + * Needs an investigation to see if this field is being used anywhere, but it is totally superfluous with the + * automatic createdAt field. + **/ + @AllowNull + @Column({ type: DATE, defaultValue: NOW }) + dateCreated: Date | null; + + @AllowNull + @Column(STRING) + createdBy: string | null; + + @Column(STRING) + auditableType: string; + + @Column(BIGINT.UNSIGNED) + auditableId: number; +} diff --git a/libs/database/src/lib/entities/delayed-job.entity.ts b/libs/database/src/lib/entities/delayed-job.entity.ts index e58e717d..e5006078 100644 --- a/libs/database/src/lib/entities/delayed-job.entity.ts +++ b/libs/database/src/lib/entities/delayed-job.entity.ts @@ -9,7 +9,7 @@ import { PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, INTEGER, STRING, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, INTEGER, STRING, UUID, UUIDV4 } from "sequelize"; import { User } from "./user.entity"; import { JsonColumn } from "../decorators/json-column.decorator"; @@ -26,7 +26,7 @@ export class DelayedJob extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @Default("pending") diff --git a/libs/database/src/lib/entities/demographic.entity.ts b/libs/database/src/lib/entities/demographic.entity.ts index 587e9f1a..67b20df7 100644 --- a/libs/database/src/lib/entities/demographic.entity.ts +++ b/libs/database/src/lib/entities/demographic.entity.ts @@ -1,5 +1,5 @@ import { AllowNull, AutoIncrement, Column, HasMany, Model, PrimaryKey, Table, Unique } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, STRING, TEXT, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, STRING, TEXT, UUID, UUIDV4 } from "sequelize"; import { DemographicEntry } from "./demographic-entry.entity"; import { Literal } from "sequelize/types/utils"; import { Subquery } from "../util/subquery.builder"; @@ -50,7 +50,7 @@ export class Demographic extends Model { override id: number; @Unique - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @Column(STRING) diff --git a/libs/database/src/lib/entities/disturbance.entity.ts b/libs/database/src/lib/entities/disturbance.entity.ts new file mode 100644 index 00000000..fcee8ab8 --- /dev/null +++ b/libs/database/src/lib/entities/disturbance.entity.ts @@ -0,0 +1,59 @@ +import { AllowNull, AutoIncrement, Column, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; +import { BIGINT, INTEGER, STRING, TEXT, TINYINT, UUID, UUIDV4 } from "sequelize"; + +@Table({ tableName: "v2_disturbances", underscored: true, paranoid: true }) +export class Disturbance extends Model { + static readonly LARAVEL_TYPE = "App\\Models\\SiteSubmissionDisturbance"; + + @PrimaryKey + @AutoIncrement + @Column(BIGINT.UNSIGNED) + override id: number; + + @Index + @Column({ type: UUID, defaultValue: UUIDV4 }) + uuid: string; + + @Column(STRING) + disturbanceableType: string; + + @Column(BIGINT.UNSIGNED) + disturbanceableId: number; + + @AllowNull + @Column(STRING) + collection: string | null; + + @AllowNull + @Column(STRING) + type: string | null; + + @AllowNull + @Column(STRING) + intensity: string | null; + + @AllowNull + @Column(STRING) + extent: string | null; + + @AllowNull + @Column(TEXT) + description: string | null; + + /** + * @deprecated This property is no longer in use and will be removed in future versions. + */ + @AllowNull + @Column(INTEGER.UNSIGNED) + oldId: number; + + /** + * @deprecated This property is no longer in use and will be removed in future versions. + */ + @AllowNull + @Column(STRING) + oldModel: string | null; + + @Column(TINYINT) + hidden: number | null; +} diff --git a/libs/database/src/lib/entities/form-question.entity.ts b/libs/database/src/lib/entities/form-question.entity.ts new file mode 100644 index 00000000..8c71243a --- /dev/null +++ b/libs/database/src/lib/entities/form-question.entity.ts @@ -0,0 +1,120 @@ +import { AllowNull, AutoIncrement, BelongsTo, Column, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; +import { BIGINT, BOOLEAN, INTEGER, STRING, TEXT, TINYINT, UUID, UUIDV4 } from "sequelize"; +import { I18nItem } from "./i18n-item.entity"; +import { JsonColumn } from "../decorators/json-column.decorator"; + +@Table({ tableName: "form_questions", underscored: true, paranoid: true }) +export class FormQuestion extends Model { + @PrimaryKey + @AutoIncrement + @Column(BIGINT.UNSIGNED) + override id: number; + + @Index + @Column({ type: UUID, defaultValue: UUIDV4 }) + uuid: string; + + // TODO: foreign key for FormSection, when that's been added. Uses cascading delete constraint + @Column(BIGINT.UNSIGNED) + formSectionId: number; + + // TODO: foreign key on Form UUID + @AllowNull + @Column(UUID) + parentId: string; + + @AllowNull + @Column(STRING) + linkedFieldKey: string | null; + + @Column(STRING) + inputType: string; + + @AllowNull + @Column(STRING) + name: string | null; + + @Column(TEXT) + label: string; + + @AllowNull + @Column(INTEGER) + labelId: number | null; + + @BelongsTo(() => I18nItem, { foreignKey: "label_id", constraints: false }) + labelI18nItem: I18nItem | null; + + @AllowNull + @Column(TEXT) + description: string | null; + + @AllowNull + @Column(INTEGER) + descriptionId: number | null; + + @BelongsTo(() => I18nItem, { foreignKey: "description_id", constraints: false }) + descriptionI18nItem: I18nItem | null; + + @AllowNull + @Column(STRING) + placeholder: string | null; + + @AllowNull + @Column(INTEGER) + placeholderId: number | null; + + @BelongsTo(() => I18nItem, { foreignKey: "placeholder_id", constraints: false }) + placeholderI18nItem: I18nItem | null; + + @AllowNull + @Column(STRING) + optionsList: string | null; + + @Column({ type: BOOLEAN, field: "multichoice", defaultValue: false }) + multiChoice: boolean; + + @AllowNull + @Column(STRING) + collection: string | null; + + @Column(TINYINT) + order: number; + + @AllowNull + @JsonColumn() + additionalProps: object | null; + + @AllowNull + @Column(TEXT("tiny")) + additionalText: string | null; + + @AllowNull + @Column(STRING) + additionalUrl: string | null; + + @AllowNull + @Column(BOOLEAN) + showOnParentCondition: boolean | null; + + @AllowNull + @JsonColumn() + validation: object | null; + + @AllowNull + @Column({ type: BOOLEAN, defaultValue: false }) + optionsOther: boolean | null; + + @Column({ type: BOOLEAN, defaultValue: true }) + conditionalDefault: boolean; + + @Column({ type: BOOLEAN, defaultValue: false }) + isParentConditionalDefault: boolean; + + @AllowNull + @Column({ type: INTEGER.UNSIGNED, defaultValue: 0 }) + minCharacterLimit: number | null; + + @AllowNull + @Column({ type: INTEGER.UNSIGNED, defaultValue: 90000 }) + maxCharacterLimit: number | null; +} diff --git a/libs/database/src/lib/entities/form-submission.entity.ts b/libs/database/src/lib/entities/form-submission.entity.ts index b7ae8af2..f70c06ca 100644 --- a/libs/database/src/lib/entities/form-submission.entity.ts +++ b/libs/database/src/lib/entities/form-submission.entity.ts @@ -9,7 +9,7 @@ import { Table, Unique } from "sequelize-typescript"; -import { BIGINT, CHAR, STRING, UUID } from "sequelize"; +import { BIGINT, CHAR, STRING, UUID, UUIDV4 } from "sequelize"; import { Application } from "./application.entity"; import { FormSubmissionStatus } from "../constants/status"; @@ -22,7 +22,7 @@ export class FormSubmission extends Model { override id: number; @Unique - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @Column(STRING) diff --git a/libs/database/src/lib/entities/framework.entity.ts b/libs/database/src/lib/entities/framework.entity.ts index 163d676c..a4f81964 100644 --- a/libs/database/src/lib/entities/framework.entity.ts +++ b/libs/database/src/lib/entities/framework.entity.ts @@ -1,5 +1,5 @@ import { AutoIncrement, Column, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, STRING, UUID } from "sequelize"; +import { BIGINT, STRING, UUID, UUIDV4 } from "sequelize"; // Incomplete stub @Table({ tableName: "frameworks", underscored: true }) @@ -10,7 +10,7 @@ export class Framework extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @Index diff --git a/libs/database/src/lib/entities/funding-programme.entity.ts b/libs/database/src/lib/entities/funding-programme.entity.ts index 28dc6da1..2402a765 100644 --- a/libs/database/src/lib/entities/funding-programme.entity.ts +++ b/libs/database/src/lib/entities/funding-programme.entity.ts @@ -1,8 +1,9 @@ import { AllowNull, AutoIncrement, BelongsTo, Column, Model, PrimaryKey, Table, Unique } from "sequelize-typescript"; -import { BIGINT, INTEGER, STRING, TEXT, UUID } from "sequelize"; +import { BIGINT, INTEGER, STRING, TEXT, UUID, UUIDV4 } from "sequelize"; import { FrameworkKey } from "../constants/framework"; import { Framework } from "./framework.entity"; import { JsonColumn } from "../decorators/json-column.decorator"; +import { I18nItem } from "./i18n-item.entity"; @Table({ tableName: "funding_programmes", underscored: true, paranoid: true }) export class FundingProgramme extends Model { @@ -12,7 +13,7 @@ export class FundingProgramme extends Model { override id: number; @Unique - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @Column(STRING) @@ -22,9 +23,8 @@ export class FundingProgramme extends Model { @Column(INTEGER) nameId: number | null; - // TODO after TM-1861 is merged - // @BelongsTo(() => I18nItem, { foreignKey: "name_id", constraints: false }) - // nameI18nItem: I18nItem | null; + @BelongsTo(() => I18nItem, { foreignKey: "name_id", constraints: false }) + nameI18nItem: I18nItem | null; @AllowNull @Column(STRING) @@ -43,9 +43,8 @@ export class FundingProgramme extends Model { @Column(INTEGER) descriptionId: number | null; - // TODO after TM-1861 is merged - // @BelongsTo(() => I18nItem, { foreignKey: "description_id", constraints: false }) - // descriptionI18nItem: I18nItem | null; + @BelongsTo(() => I18nItem, { foreignKey: "description_id", constraints: false }) + descriptionI18nItem: I18nItem | null; @AllowNull @Column(TEXT) @@ -63,7 +62,6 @@ export class FundingProgramme extends Model { @Column(INTEGER) locationId: number | null; - // TODO after TM-1861 is merged - // @BelongsTo(() => I18nItem, { foreignKey: "location_id", constraints: false }) - // locationI18nItem: I18nItem | null; + @BelongsTo(() => I18nItem, { foreignKey: "location_id", constraints: false }) + locationI18nItem: I18nItem | null; } diff --git a/libs/database/src/lib/entities/i18n-item.entity.ts b/libs/database/src/lib/entities/i18n-item.entity.ts index 9e2444f6..65784e3d 100644 --- a/libs/database/src/lib/entities/i18n-item.entity.ts +++ b/libs/database/src/lib/entities/i18n-item.entity.ts @@ -2,7 +2,7 @@ import { AllowNull, AutoIncrement, Column, Model, PrimaryKey, Table } from "sequ import { BIGINT, STRING, TEXT } from "sequelize"; @Table({ tableName: "i18n_items", underscored: true }) -export class i18nItem extends Model { +export class I18nItem extends Model { @PrimaryKey @AutoIncrement @Column(BIGINT.UNSIGNED) diff --git a/libs/database/src/lib/entities/i18n-translation.entity.ts b/libs/database/src/lib/entities/i18n-translation.entity.ts index ae4a76c9..41fc9e20 100644 --- a/libs/database/src/lib/entities/i18n-translation.entity.ts +++ b/libs/database/src/lib/entities/i18n-translation.entity.ts @@ -1,15 +1,14 @@ -import { AllowNull, AutoIncrement, Column, Model, PrimaryKey, Table, Unique } from "sequelize-typescript"; +import { AllowNull, AutoIncrement, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; import { BIGINT, INTEGER, STRING, TEXT } from "sequelize"; @Table({ tableName: "i18n_translations", underscored: true }) -export class i18nTranslation extends Model { +export class I18nTranslation extends Model { @PrimaryKey @AutoIncrement @Column(BIGINT.UNSIGNED) override id: number; @AllowNull - @Unique @Column(INTEGER({ length: 11 })) i18nItemId: number; diff --git a/libs/database/src/lib/entities/index.ts b/libs/database/src/lib/entities/index.ts index e55e0b23..814fe76c 100644 --- a/libs/database/src/lib/entities/index.ts +++ b/libs/database/src/lib/entities/index.ts @@ -1,9 +1,12 @@ export * from "./action.entity"; +export * from "./audit-status.entity"; export * from "./application.entity"; export * from "./delayed-job.entity"; export * from "./demographic.entity"; export * from "./demographic-entry.entity"; +export * from "./disturbance.entity"; export * from "./form-submission.entity"; +export * from "./form-question.entity"; export * from "./framework.entity"; export * from "./framework-user.entity"; export * from "./funding-programme.entity"; @@ -15,8 +18,9 @@ export * from "./indicator-output-msu-carbon.entity"; export * from "./indicator-output-tree-count.entity"; export * from "./indicator-output-tree-cover.entity"; export * from "./indicator-output-tree-cover-loss.entity"; +export * from "./invasive.entity"; export * from "./landscape-geometry.entity"; -export * from "./localization-keys.entity"; +export * from "./localization-key"; export * from "./media.entity"; export * from "./model-has-role.entity"; export * from "./nursery.entity"; @@ -35,8 +39,10 @@ export * from "./seeding.entity"; export * from "./site.entity"; export * from "./site-polygon.entity"; export * from "./site-report.entity"; +export * from "./stratas.entity"; export * from "./tree-species.entity"; export * from "./tree-species-research.entity"; +export * from "./update-request.entity"; export * from "./user.entity"; export * from "./verification.entity"; export * from "./task.entity"; diff --git a/libs/database/src/lib/entities/invasive.entity.ts b/libs/database/src/lib/entities/invasive.entity.ts new file mode 100644 index 00000000..aecd83eb --- /dev/null +++ b/libs/database/src/lib/entities/invasive.entity.ts @@ -0,0 +1,51 @@ +import { AllowNull, AutoIncrement, Column, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; +import { BIGINT, INTEGER, STRING, TEXT, TINYINT, UUID, UUIDV4 } from "sequelize"; + +@Table({ tableName: "v2_invasives", underscored: true, paranoid: true }) +export class Invasive extends Model { + static readonly LARAVEL_TYPE = "App\\Models\\Invasive"; + + @PrimaryKey + @AutoIncrement + @Column(BIGINT.UNSIGNED) + override id: number; + + @Index + @Column({ type: UUID, defaultValue: UUIDV4 }) + uuid: string; + + @Column(STRING) + invasiveableType: string; + + @Column(BIGINT.UNSIGNED) + invasiveableId: number; + + @AllowNull + @Column(STRING) + collection: string | null; + + @AllowNull + @Column(STRING) + type: string | null; + + @AllowNull + @Column(TEXT) + name: string | null; + + /** + * @deprecated This property is no longer in use and will be removed in future versions. + */ + @AllowNull + @Column(INTEGER.UNSIGNED) + oldId: number; + + /** + * @deprecated This property is no longer in use and will be removed in future versions. + */ + @AllowNull + @Column(STRING) + oldModel: string | null; + + @Column(TINYINT) + hidden: number | null; +} diff --git a/libs/database/src/lib/entities/localization-keys.entity.ts b/libs/database/src/lib/entities/localization-key.ts similarity index 61% rename from libs/database/src/lib/entities/localization-keys.entity.ts rename to libs/database/src/lib/entities/localization-key.ts index 2e0a1895..92bd37d8 100644 --- a/libs/database/src/lib/entities/localization-keys.entity.ts +++ b/libs/database/src/lib/entities/localization-key.ts @@ -1,5 +1,6 @@ -import { AllowNull, AutoIncrement, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; +import { AllowNull, AutoIncrement, BelongsTo, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; import { BIGINT, INTEGER, STRING, TEXT } from "sequelize"; +import { I18nItem } from "./i18n-item.entity"; @Table({ tableName: "localization_keys", underscored: true }) export class LocalizationKey extends Model { @@ -17,4 +18,7 @@ export class LocalizationKey extends Model { @AllowNull @Column(INTEGER({ length: 11 })) valueId: number; + + @BelongsTo(() => I18nItem, { foreignKey: "value_id", constraints: false }) + i18nItem: I18nItem; } diff --git a/libs/database/src/lib/entities/media.entity.ts b/libs/database/src/lib/entities/media.entity.ts index 0d070620..f8c256b7 100644 --- a/libs/database/src/lib/entities/media.entity.ts +++ b/libs/database/src/lib/entities/media.entity.ts @@ -10,54 +10,19 @@ import { Table, Unique } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, DOUBLE, ENUM, INTEGER, STRING, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, DOUBLE, ENUM, INTEGER, STRING, UUID, UUIDV4 } from "sequelize"; import { JsonColumn } from "../decorators/json-column.decorator"; import { User } from "./user.entity"; -import { Project } from "./project.entity"; import { chainScope } from "../util/chain-scope"; -import { Nursery } from "./nursery.entity"; -import { Site } from "./site.entity"; -import { NurseryReport } from "./nursery-report.entity"; -import { ProjectReport } from "./project-report.entity"; -import { SiteReport } from "./site-report.entity"; +import { LaravelModel, laravelType } from "../types/util"; @DefaultScope(() => ({ order: ["orderColumn"] })) @Scopes(() => ({ collection: (collectionName: string) => ({ where: { collectionName } }), - project: (id: number) => ({ + association: (association: LaravelModel) => ({ where: { - modelType: Project.LARAVEL_TYPE, - modelId: id - } - }), - nursery: (id: number) => ({ - where: { - modelType: Nursery.LARAVEL_TYPE, - modelId: id - } - }), - site: (id: number) => ({ - where: { - modelType: Site.LARAVEL_TYPE, - modelId: id - } - }), - nurseryReport: (id: number) => ({ - where: { - modelType: NurseryReport.LARAVEL_TYPE, - modelId: id - } - }), - projectReport: (id: number) => ({ - where: { - modelType: ProjectReport.LARAVEL_TYPE, - modelId: id - } - }), - siteReport: (id: number) => ({ - where: { - modelType: SiteReport.LARAVEL_TYPE, - modelId: id + modelType: laravelType(association), + modelId: association.id } }) })) @@ -75,28 +40,8 @@ export class Media extends Model { return chainScope(this, "collection", collectionName) as typeof Media; } - static project(id: number) { - return chainScope(this, "project", id) as typeof Media; - } - - static site(id: number) { - return chainScope(this, "site", id) as typeof Media; - } - - static nursery(id: number) { - return chainScope(this, "nursery", id) as typeof Media; - } - - static nurseryReport(id: number) { - return chainScope(this, "nurseryReport", id) as typeof Media; - } - - static projectReport(id: number) { - return chainScope(this, "projectReport", id) as typeof Media; - } - - static siteReport(id: number) { - return chainScope(this, "siteReport", id) as typeof Media; + static for(model: LaravelModel) { + return chainScope(this, "association", model) as typeof Media; } @PrimaryKey @@ -105,7 +50,7 @@ export class Media extends Model { override id: number; @Unique - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @Column(STRING) diff --git a/libs/database/src/lib/entities/nursery-report.entity.ts b/libs/database/src/lib/entities/nursery-report.entity.ts index 3288100f..2fcb20f0 100644 --- a/libs/database/src/lib/entities/nursery-report.entity.ts +++ b/libs/database/src/lib/entities/nursery-report.entity.ts @@ -11,10 +11,10 @@ import { Scopes, Table } from "sequelize-typescript"; -import { BIGINT, DATE, INTEGER, Op, STRING, TEXT, TINYINT, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, DATE, INTEGER, Op, STRING, TEXT, UUID, UUIDV4 } from "sequelize"; import { Nursery } from "./nursery.entity"; import { TreeSpecies } from "./tree-species.entity"; -import { COMPLETE_REPORT_STATUSES, ReportStatus, UpdateRequestStatus } from "../constants/status"; +import { COMPLETE_REPORT_STATUSES, ReportStatus, ReportStatusStates, UpdateRequestStatus } from "../constants/status"; import { FrameworkKey } from "../constants/framework"; import { Literal } from "sequelize/types/utils"; import { chainScope } from "../util/chain-scope"; @@ -22,13 +22,14 @@ import { Subquery } from "../util/subquery.builder"; import { User } from "./user.entity"; import { JsonColumn } from "../decorators/json-column.decorator"; import { Task } from "./task.entity"; +import { StateMachineColumn } from "../util/model-column-state-machine"; // Incomplete stub @Scopes(() => ({ incomplete: { where: { status: { [Op.notIn]: COMPLETE_REPORT_STATUSES } } }, nurseries: (ids: number[] | Literal) => ({ where: { nurseryId: { [Op.in]: ids } } }), approved: { where: { status: { [Op.in]: NurseryReport.APPROVED_STATUSES } } }, - task: (taskId: number) => ({ where: { taskId: taskId } }) + task: (taskId: number) => ({ where: { taskId } }) })) @Table({ tableName: "v2_nursery_reports", underscored: true, paranoid: true }) export class NurseryReport extends Model { @@ -72,7 +73,7 @@ export class NurseryReport extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull @@ -103,9 +104,6 @@ export class NurseryReport extends Model { @BelongsTo(() => User, { foreignKey: "approvedBy", as: "approvedByUser" }) approvedByUser: User | null; - @BelongsTo(() => Task) - task: Task | null; - get projectName() { return this.nursery?.project?.name; } @@ -155,7 +153,10 @@ export class NurseryReport extends Model { @Column(BIGINT.UNSIGNED) taskId: number; - @Column(STRING) + @BelongsTo(() => Task, { constraints: false }) + task: Task | null; + + @StateMachineColumn(ReportStatusStates) status: ReportStatus; @AllowNull @@ -171,7 +172,7 @@ export class NurseryReport extends Model { seedlingsYoungTrees: number | null; @AllowNull - @Column(TINYINT) + @Column(BOOLEAN) nothingToReport: boolean; @AllowNull diff --git a/libs/database/src/lib/entities/nursery.entity.ts b/libs/database/src/lib/entities/nursery.entity.ts index 6c23da63..4212cc62 100644 --- a/libs/database/src/lib/entities/nursery.entity.ts +++ b/libs/database/src/lib/entities/nursery.entity.ts @@ -11,15 +11,16 @@ import { Scopes, Table } from "sequelize-typescript"; -import { BIGINT, DATE, INTEGER, Op, STRING, TEXT, UUID } from "sequelize"; +import { BIGINT, DATE, INTEGER, Op, STRING, TEXT, UUID, UUIDV4 } from "sequelize"; import { Project } from "./project.entity"; import { TreeSpecies } from "./tree-species.entity"; import { NurseryReport } from "./nursery-report.entity"; -import { EntityStatus, UpdateRequestStatus } from "../constants/status"; +import { EntityStatus, EntityStatusStates, UpdateRequestStatus } from "../constants/status"; import { chainScope } from "../util/chain-scope"; import { Subquery } from "../util/subquery.builder"; import { FrameworkKey } from "../constants/framework"; import { JsonColumn } from "../decorators/json-column.decorator"; +import { StateMachineColumn } from "../util/model-column-state-machine"; // Incomplete stub @Scopes(() => ({ @@ -56,10 +57,10 @@ export class Nursery extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; - @Column(STRING) + @StateMachineColumn(EntityStatusStates) status: EntityStatus; @AllowNull diff --git a/libs/database/src/lib/entities/organisation.entity.ts b/libs/database/src/lib/entities/organisation.entity.ts index 3ff173a6..4fdb74a6 100644 --- a/libs/database/src/lib/entities/organisation.entity.ts +++ b/libs/database/src/lib/entities/organisation.entity.ts @@ -1,5 +1,5 @@ import { AllowNull, AutoIncrement, Column, Default, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, DATE, DECIMAL, ENUM, INTEGER, STRING, TEXT, TINYINT, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, DATE, DECIMAL, ENUM, INTEGER, STRING, TEXT, TINYINT, UUID, UUIDV4 } from "sequelize"; import { JsonColumn } from "../decorators/json-column.decorator"; import { OrganisationStatus } from "../constants/status"; @@ -13,7 +13,7 @@ export class Organisation extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @Default("draft") diff --git a/libs/database/src/lib/entities/point-geometry.entity.ts b/libs/database/src/lib/entities/point-geometry.entity.ts index 16822fe2..a21a9d73 100644 --- a/libs/database/src/lib/entities/point-geometry.entity.ts +++ b/libs/database/src/lib/entities/point-geometry.entity.ts @@ -1,5 +1,5 @@ import { AllowNull, AutoIncrement, Column, ForeignKey, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, DECIMAL, GEOMETRY, UUID } from "sequelize"; +import { BIGINT, DECIMAL, GEOMETRY, UUID, UUIDV4 } from "sequelize"; import { Point } from "geojson"; import { User } from "./user.entity"; @@ -11,7 +11,7 @@ export class PointGeometry extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull diff --git a/libs/database/src/lib/entities/polygon-geometry.entity.ts b/libs/database/src/lib/entities/polygon-geometry.entity.ts index 719ece09..89455b75 100644 --- a/libs/database/src/lib/entities/polygon-geometry.entity.ts +++ b/libs/database/src/lib/entities/polygon-geometry.entity.ts @@ -1,5 +1,5 @@ import { AllowNull, AutoIncrement, Column, ForeignKey, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, GEOMETRY, UUID } from "sequelize"; +import { BIGINT, GEOMETRY, UUID, UUIDV4 } from "sequelize"; import { Polygon } from "geojson"; import { User } from "./user.entity"; @@ -11,7 +11,7 @@ export class PolygonGeometry extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull diff --git a/libs/database/src/lib/entities/project-pitch.entity.ts b/libs/database/src/lib/entities/project-pitch.entity.ts index d5b391fe..784cfefe 100644 --- a/libs/database/src/lib/entities/project-pitch.entity.ts +++ b/libs/database/src/lib/entities/project-pitch.entity.ts @@ -1,5 +1,5 @@ import { AllowNull, AutoIncrement, Column, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, DATE, INTEGER, STRING, TEXT, TINYINT, UUID } from "sequelize"; +import { BIGINT, DATE, INTEGER, STRING, TEXT, TINYINT, UUID, UUIDV4 } from "sequelize"; import { JsonColumn } from "../decorators/json-column.decorator"; @Table({ tableName: "project_pitches", underscored: true, paranoid: true }) @@ -12,7 +12,7 @@ export class ProjectPitch extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull diff --git a/libs/database/src/lib/entities/project-report.entity.ts b/libs/database/src/lib/entities/project-report.entity.ts index d07c8d06..772de6d9 100644 --- a/libs/database/src/lib/entities/project-report.entity.ts +++ b/libs/database/src/lib/entities/project-report.entity.ts @@ -11,11 +11,11 @@ import { Scopes, Table } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, DATE, INTEGER, Op, STRING, TEXT, TINYINT, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, DATE, INTEGER, Op, STRING, TEXT, TINYINT, UUID, UUIDV4 } from "sequelize"; import { TreeSpecies } from "./tree-species.entity"; import { Project } from "./project.entity"; import { FrameworkKey } from "../constants/framework"; -import { COMPLETE_REPORT_STATUSES } from "../constants/status"; +import { COMPLETE_REPORT_STATUSES, ReportStatus, ReportStatusStates, UpdateRequestStatus } from "../constants/status"; import { chainScope } from "../util/chain-scope"; import { Subquery } from "../util/subquery.builder"; import { Framework } from "./framework.entity"; @@ -23,6 +23,8 @@ import { SiteReport } from "./site-report.entity"; import { Literal } from "sequelize/types/utils"; import { User } from "./user.entity"; import { Task } from "./task.entity"; +import { StateMachineColumn } from "../util/model-column-state-machine"; +import { JsonColumn } from "../decorators/json-column.decorator"; type ApprovedIdsSubqueryOptions = { dueAfter?: string | Date; @@ -34,7 +36,8 @@ type ApprovedIdsSubqueryOptions = { incomplete: { where: { status: { [Op.notIn]: COMPLETE_REPORT_STATUSES } } }, approved: { where: { status: { [Op.in]: ProjectReport.APPROVED_STATUSES } } }, project: (id: number) => ({ where: { projectId: id } }), - dueBefore: (date: Date | string) => ({ where: { dueAt: { [Op.lt]: date } } }) + dueBefore: (date: Date | string) => ({ where: { dueAt: { [Op.lt]: date } } }), + task: (taskId: number) => ({ where: { taskId } }) })) @Table({ tableName: "v2_project_reports", underscored: true, paranoid: true }) export class ProjectReport extends Model { @@ -48,7 +51,26 @@ export class ProjectReport extends Model { media: { dbCollection: "media", multiple: true }, file: { dbCollection: "file", multiple: true }, otherAdditionalDocuments: { dbCollection: "other_additional_documents", multiple: true }, - photos: { dbCollection: "photos", multiple: true } + photos: { dbCollection: "photos", multiple: true }, + baselineReportUpload: { dbCollection: "baseline_report_upload", multiple: true }, + localGovernanceOrderLetterUpload: { dbCollection: "local_governance_order_letter_upload", multiple: true }, + eventsMeetingsPhotos: { dbCollection: "events_meetings_photos", multiple: true }, + localGovernanceProofOfPartnershipUpload: { + dbCollection: "local_governance_proof_of_partnership_upload", + multiple: true + }, + topThreeSuccessesUpload: { dbCollection: "top_three_successes_upload", multiple: true }, + directJobsUpload: { dbCollection: "direct_jobs_upload", multiple: true }, + convergenceJobsUpload: { dbCollection: "convergence_jobs_upload", multiple: true }, + convergenceSchemesUpload: { dbCollection: "convergence_schemes_upload", multiple: true }, + livelihoodActivitiesUpload: { dbCollection: "livelihood_activities_upload", multiple: true }, + directLivelihoodImpactsUpload: { dbCollection: "direct_livelihood_impacts_upload", multiple: true }, + certifiedDatabaseUpload: { dbCollection: "certified_database_upload", multiple: true }, + physicalAssetsPhotos: { dbCollection: "physical_assets_photos", multiple: true }, + indirectCommunityPartnersUpload: { dbCollection: "indirect_community_partners_upload", multiple: true }, + trainingCapacityBuildingUpload: { dbCollection: "training_capacity_building_upload", multiple: true }, + trainingCapacityBuildingPhotos: { dbCollection: "training_capacity_building_photos", multiple: true }, + financialReportUpload: { dbCollection: "financial_report_upload", multiple: true } } as const; static incomplete() { @@ -97,13 +119,17 @@ export class ProjectReport extends Model { return builder.literal; } + static task(taskId: number) { + return chainScope(this, "task", taskId) as typeof ProjectReport; + } + @PrimaryKey @AutoIncrement @Column(BIGINT.UNSIGNED) override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull @@ -131,9 +157,6 @@ export class ProjectReport extends Model { @BelongsTo(() => User) user: User | null; - @BelongsTo(() => Task) - task: Task | null; - get projectName() { return this.project?.name; } @@ -161,21 +184,24 @@ export class ProjectReport extends Model { @ForeignKey(() => Task) @AllowNull @Column(BIGINT.UNSIGNED) - taskId: number; + taskId: number | null; - @Column(STRING) - status: string; + @BelongsTo(() => Task, { constraints: false }) + task: Task | null; + + @StateMachineColumn(ReportStatusStates) + status: ReportStatus; @AllowNull @Column(STRING) - updateRequestStatus: string; + updateRequestStatus: UpdateRequestStatus | null; @AllowNull @Column(TEXT) feedback: string | null; @AllowNull - @Column(TEXT) + @JsonColumn() feedbackFields: string[] | null; @AllowNull diff --git a/libs/database/src/lib/entities/project-user.entity.ts b/libs/database/src/lib/entities/project-user.entity.ts index 72fc7162..f83e3e5b 100644 --- a/libs/database/src/lib/entities/project-user.entity.ts +++ b/libs/database/src/lib/entities/project-user.entity.ts @@ -14,6 +14,10 @@ export class ProjectUser extends Model { return Subquery.select(ProjectUser, "projectId").eq("userId", userId).eq("isManaging", true).literal; } + static projectUsersSubquery(projectId: number) { + return Subquery.select(ProjectUser, "userId").eq("projectId", projectId).literal; + } + @PrimaryKey @AutoIncrement @Column(BIGINT.UNSIGNED) diff --git a/libs/database/src/lib/entities/project.entity.ts b/libs/database/src/lib/entities/project.entity.ts index ca958132..4257affe 100644 --- a/libs/database/src/lib/entities/project.entity.ts +++ b/libs/database/src/lib/entities/project.entity.ts @@ -11,7 +11,7 @@ import { PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, DATE, DECIMAL, ENUM, INTEGER, STRING, TEXT, TINYINT, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, DATE, DECIMAL, ENUM, INTEGER, STRING, TEXT, TINYINT, UUID, UUIDV4 } from "sequelize"; import { Organisation } from "./organisation.entity"; import { TreeSpecies } from "./tree-species.entity"; import { ProjectReport } from "./project-report.entity"; @@ -21,8 +21,9 @@ import { Nursery } from "./nursery.entity"; import { JsonColumn } from "../decorators/json-column.decorator"; import { FrameworkKey } from "../constants/framework"; import { Framework } from "./framework.entity"; -import { EntityStatus, UpdateRequestStatus } from "../constants/status"; +import { EntityStatus, EntityStatusStates, UpdateRequestStatus } from "../constants/status"; import { Subquery } from "../util/subquery.builder"; +import { StateMachineColumn } from "../util/model-column-state-machine"; @Table({ tableName: "v2_projects", underscored: true, paranoid: true }) export class Project extends Model { @@ -59,7 +60,7 @@ export class Project extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull @@ -95,9 +96,8 @@ export class Project extends Model { @Column(BIGINT.UNSIGNED) applicationId: number | null; - @AllowNull - @Column(STRING) - status: EntityStatus | null; + @StateMachineColumn(EntityStatusStates) + status: EntityStatus; @AllowNull @Default("no-update") diff --git a/libs/database/src/lib/entities/seeding.entity.ts b/libs/database/src/lib/entities/seeding.entity.ts index 62594392..37609540 100644 --- a/libs/database/src/lib/entities/seeding.entity.ts +++ b/libs/database/src/lib/entities/seeding.entity.ts @@ -9,7 +9,7 @@ import { Table, Unique } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, DOUBLE, Op, STRING, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, DOUBLE, Op, STRING, UUID, UUIDV4 } from "sequelize"; import { TreeSpeciesResearch } from "./tree-species-research.entity"; import { Literal } from "sequelize/types/utils"; import { SiteReport } from "./site-report.entity"; @@ -46,7 +46,7 @@ export class Seeding extends Model { override id: number; @Unique - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull diff --git a/libs/database/src/lib/entities/site-polygon.entity.ts b/libs/database/src/lib/entities/site-polygon.entity.ts index 5929aac4..a9687cd9 100644 --- a/libs/database/src/lib/entities/site-polygon.entity.ts +++ b/libs/database/src/lib/entities/site-polygon.entity.ts @@ -12,7 +12,7 @@ import { Scopes, Table } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, DATE, DOUBLE, INTEGER, Op, STRING, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, DATE, DOUBLE, INTEGER, Op, STRING, UUID, UUIDV4 } from "sequelize"; import { Site } from "./site.entity"; import { PointGeometry } from "./point-geometry.entity"; import { PolygonGeometry } from "./polygon-geometry.entity"; @@ -61,7 +61,7 @@ export class SitePolygon extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @Column(UUID) diff --git a/libs/database/src/lib/entities/site-report.entity.ts b/libs/database/src/lib/entities/site-report.entity.ts index 11f07dcb..969c9262 100644 --- a/libs/database/src/lib/entities/site-report.entity.ts +++ b/libs/database/src/lib/entities/site-report.entity.ts @@ -11,18 +11,19 @@ import { Scopes, Table } from "sequelize-typescript"; -import { BIGINT, DATE, INTEGER, Op, STRING, TEXT, TINYINT, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, DATE, INTEGER, Op, STRING, TEXT, UUID, UUIDV4 } from "sequelize"; import { TreeSpecies } from "./tree-species.entity"; import { Site } from "./site.entity"; import { Seeding } from "./seeding.entity"; import { FrameworkKey } from "../constants/framework"; import { Literal } from "sequelize/types/utils"; -import { COMPLETE_REPORT_STATUSES } from "../constants/status"; +import { COMPLETE_REPORT_STATUSES, ReportStatus, ReportStatusStates, UpdateRequestStatus } from "../constants/status"; import { chainScope } from "../util/chain-scope"; import { Subquery } from "../util/subquery.builder"; import { Task } from "./task.entity"; import { User } from "./user.entity"; import { JsonColumn } from "../decorators/json-column.decorator"; +import { StateMachineColumn } from "../util/model-column-state-machine"; type ApprovedIdsSubqueryOptions = { dueAfter?: string | Date; @@ -35,7 +36,7 @@ type ApprovedIdsSubqueryOptions = { sites: (ids: number[] | Literal) => ({ where: { siteId: { [Op.in]: ids } } }), approved: { where: { status: { [Op.in]: SiteReport.APPROVED_STATUSES } } }, dueBefore: (date: Date | string) => ({ where: { dueAt: { [Op.lt]: date } } }), - task: (taskId: number) => ({ where: { taskId: taskId } }) + task: (taskId: number) => ({ where: { taskId } }) })) @Table({ tableName: "v2_site_reports", underscored: true, paranoid: true }) export class SiteReport extends Model { @@ -53,7 +54,11 @@ export class SiteReport extends Model { photos: { dbCollection: "photos", multiple: true }, treeSpecies: { dbCollection: "tree_species", multiple: true }, siteSubmission: { dbCollection: "site_submission", multiple: true }, - documentFiles: { dbCollection: "document_files", multiple: true } + documentFiles: { dbCollection: "document_files", multiple: true }, + treePlantingUpload: { dbCollection: "tree_planting_upload", multiple: true }, + anrPhotos: { dbCollection: "anr_photos", multiple: true }, + soilWaterConservationUpload: { dbCollection: "soil_water_conservation_upload", multiple: true }, + soilWaterConservationPhotos: { dbCollection: "soil_water_conservation_photos", multiple: true } } as const; static incomplete() { @@ -93,7 +98,7 @@ export class SiteReport extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull @@ -116,9 +121,6 @@ export class SiteReport extends Model { @BelongsTo(() => User, { foreignKey: "approvedBy", as: "approvedByUser" }) approvedByUser: User | null; - @BelongsTo(() => Task) - task: Task | null; - @ForeignKey(() => User) @Column(BIGINT.UNSIGNED) createdBy: number; @@ -132,6 +134,9 @@ export class SiteReport extends Model { @Column(BIGINT.UNSIGNED) taskId: number; + @BelongsTo(() => Task, { constraints: false }) + task: Task | null; + get projectName() { return this.site?.project?.name; } @@ -176,12 +181,12 @@ export class SiteReport extends Model { return this.approvedByUser?.lastName; } - @Column(STRING) - status: string; + @StateMachineColumn(ReportStatusStates) + status: ReportStatus; @AllowNull @Column(STRING) - updateRequestStatus: string; + updateRequestStatus: UpdateRequestStatus | null; @AllowNull @Column(DATE) @@ -304,8 +309,8 @@ export class SiteReport extends Model { feedbackFields: string[] | null; @AllowNull - @Column(TINYINT) - nothingToReport: boolean; + @Column(BOOLEAN) + nothingToReport: boolean | null; @HasMany(() => TreeSpecies, { foreignKey: "speciesableId", diff --git a/libs/database/src/lib/entities/site.entity.ts b/libs/database/src/lib/entities/site.entity.ts index 8dd6aa57..5a2a2661 100644 --- a/libs/database/src/lib/entities/site.entity.ts +++ b/libs/database/src/lib/entities/site.entity.ts @@ -11,12 +11,18 @@ import { Scopes, Table } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, DATE, DECIMAL, INTEGER, Op, STRING, TEXT, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, DATE, DECIMAL, INTEGER, Op, STRING, TEXT, UUID, UUIDV4 } from "sequelize"; import { TreeSpecies } from "./tree-species.entity"; import { SiteReport } from "./site-report.entity"; import { Project } from "./project.entity"; import { SitePolygon } from "./site-polygon.entity"; -import { APPROVED, RESTORATION_IN_PROGRESS, SiteStatus, UpdateRequestStatus } from "../constants/status"; +import { + APPROVED, + RESTORATION_IN_PROGRESS, + SiteStatus, + SiteStatusStates, + UpdateRequestStatus +} from "../constants/status"; import { SitingStrategy } from "../constants/entity-selects"; import { Seeding } from "./seeding.entity"; import { FrameworkKey } from "../constants/framework"; @@ -24,6 +30,7 @@ import { Framework } from "./framework.entity"; import { chainScope } from "../util/chain-scope"; import { Subquery } from "../util/subquery.builder"; import { JsonColumn } from "../decorators/json-column.decorator"; +import { StateMachineColumn } from "../util/model-column-state-machine"; // Incomplete stub @Scopes(() => ({ @@ -71,7 +78,7 @@ export class Site extends Model { @Column(STRING) name: string; - @Column(STRING) + @StateMachineColumn(SiteStatusStates) status: SiteStatus; @AllowNull @@ -79,7 +86,7 @@ export class Site extends Model { updateRequestStatus: UpdateRequestStatus | null; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull diff --git a/libs/database/src/lib/entities/stratas.entity.ts b/libs/database/src/lib/entities/stratas.entity.ts new file mode 100644 index 00000000..5a7219d8 --- /dev/null +++ b/libs/database/src/lib/entities/stratas.entity.ts @@ -0,0 +1,35 @@ +import { AllowNull, AutoIncrement, Column, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; +import { BIGINT, INTEGER, STRING, TINYINT, UUID, UUIDV4 } from "sequelize"; + +@Table({ tableName: "v2_stratas", underscored: true, paranoid: true }) +export class Strata extends Model { + @PrimaryKey + @AutoIncrement + @Column(BIGINT.UNSIGNED) + override id: number; + + @Index + @Column({ type: UUID, defaultValue: UUIDV4 }) + uuid: string; + + @AllowNull + @Column(INTEGER.UNSIGNED) + ownerId: number | null; + + @Column(STRING) + stratasableType: string; + + @Column(BIGINT.UNSIGNED) + stratasableId: number; + + @AllowNull + @Column(STRING) + description: string | null; + + @AllowNull + @Column(INTEGER) + extent: string | null; + + @Column(TINYINT) + hidden: number | null; +} diff --git a/libs/database/src/lib/entities/task.entity.ts b/libs/database/src/lib/entities/task.entity.ts index 1f0f5659..84d720c5 100644 --- a/libs/database/src/lib/entities/task.entity.ts +++ b/libs/database/src/lib/entities/task.entity.ts @@ -1,6 +1,23 @@ -import { AutoIncrement, Column, HasOne, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, UUID } from "sequelize"; +import { + AllowNull, + AutoIncrement, + Column, + ForeignKey, + HasMany, + HasOne, + Index, + Model, + PrimaryKey, + Table +} from "sequelize-typescript"; +import { BIGINT, DATE, STRING, UUID, UUIDV4 } from "sequelize"; +import { Organisation } from "./organisation.entity"; +import { Project } from "./project.entity"; +import { TaskStatus, TaskStatusStates } from "../constants/status"; +import { StateMachineColumn } from "../util/model-column-state-machine"; import { ProjectReport } from "./project-report.entity"; +import { SiteReport } from "./site-report.entity"; +import { NurseryReport } from "./nursery-report.entity"; @Table({ tableName: "v2_tasks", underscored: true, paranoid: true }) export class Task extends Model { @@ -12,9 +29,43 @@ export class Task extends Model { override id: number; @Index - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; + @AllowNull + @ForeignKey(() => Organisation) + @Column(BIGINT.UNSIGNED) + organisationId: number | null; + + @AllowNull + @ForeignKey(() => Project) + @Column(BIGINT.UNSIGNED) + projectId: number | null; + + /** @deprecated this field is null for all rows in the production DB. */ + @AllowNull + @Column(STRING) + title: string | null; + + @StateMachineColumn(TaskStatusStates) + status: TaskStatus; + + // Note: this column is marked nullable in the DB, but in fact no rows are null, and we should + // make that a real constraint when the schema is controlled by v3 code. + @Column(STRING) + periodKey: string; + + // Note: this column is marked nullable in the DB, but in fact no rows are null, and we should + // make that a real constraint when the schema is controlled by v3 code. + @Column(DATE) + dueAt: Date; + @HasOne(() => ProjectReport) projectReport: ProjectReport | null; + + @HasMany(() => SiteReport) + siteReports: SiteReport[] | null; + + @HasMany(() => NurseryReport) + nurseryReports: NurseryReport[] | null; } diff --git a/libs/database/src/lib/entities/tree-species.entity.ts b/libs/database/src/lib/entities/tree-species.entity.ts index fde25cbe..46b9211b 100644 --- a/libs/database/src/lib/entities/tree-species.entity.ts +++ b/libs/database/src/lib/entities/tree-species.entity.ts @@ -10,7 +10,7 @@ import { Table, Unique } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, Op, STRING, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, Op, STRING, UUID, UUIDV4 } from "sequelize"; import { TreeSpeciesResearch } from "./tree-species-research.entity"; import { Literal } from "sequelize/types/utils"; import { SiteReport } from "./site-report.entity"; @@ -77,7 +77,7 @@ export class TreeSpecies extends Model { override id: number; @Unique - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string; @AllowNull diff --git a/libs/database/src/lib/entities/update-request.entity.ts b/libs/database/src/lib/entities/update-request.entity.ts new file mode 100644 index 00000000..e88aa6d2 --- /dev/null +++ b/libs/database/src/lib/entities/update-request.entity.ts @@ -0,0 +1,84 @@ +import { + AllowNull, + AutoIncrement, + Column, + ForeignKey, + Model, + PrimaryKey, + Scopes, + Table, + Unique +} from "sequelize-typescript"; +import { BIGINT, STRING, TEXT, UUID, UUIDV4 } from "sequelize"; +import { Organisation } from "./organisation.entity"; +import { Project } from "./project.entity"; +import { User } from "./user.entity"; +import { FrameworkKey } from "../constants/framework"; +import { JsonColumn } from "../decorators/json-column.decorator"; +import { EntityClass, EntityModel } from "../constants/entities"; +import { chainScope } from "../util/chain-scope"; + +@Scopes(() => ({ + entity: (entity: T) => ({ + where: { + updateRequestableType: (entity.constructor as EntityClass).LARAVEL_TYPE, + updateRequestableId: entity.id + } + }) +})) +@Table({ tableName: "v2_update_requests", underscored: true, paranoid: true }) +export class UpdateRequest extends Model { + static for(entity: T) { + return chainScope(this, "entity", entity) as typeof UpdateRequest; + } + + @PrimaryKey + @AutoIncrement + @Column(BIGINT.UNSIGNED) + override id: number; + + @Unique + @Column({ type: UUID, defaultValue: UUIDV4 }) + uuid: string; + + @AllowNull + @ForeignKey(() => Organisation) + @Column(BIGINT.UNSIGNED) + organisationId: number | null; + + @AllowNull + @ForeignKey(() => Project) + @Column(BIGINT.UNSIGNED) + projectId: number | null; + + @AllowNull + @ForeignKey(() => User) + @Column(BIGINT.UNSIGNED) + createdById: number | null; + + @AllowNull + @Column(STRING) + frameworkKey: FrameworkKey | null; + + @AllowNull + @Column(STRING) + status: string; + + @AllowNull + @JsonColumn() + content: object | null; + + @AllowNull + @Column(TEXT) + feedback: string | null; + + @AllowNull + @JsonColumn() + feedbackFields: string[] | null; + + @Column({ type: STRING, field: "updaterequestable_type" }) + updateRequestableType: string; + + @Column({ type: BIGINT.UNSIGNED, field: "updaterequestable_id" }) + updateRequestableId: number; +} diff --git a/libs/database/src/lib/entities/user.entity.ts b/libs/database/src/lib/entities/user.entity.ts index 3926f6de..c4431d99 100644 --- a/libs/database/src/lib/entities/user.entity.ts +++ b/libs/database/src/lib/entities/user.entity.ts @@ -13,7 +13,7 @@ import { Table, Unique } from "sequelize-typescript"; -import { BIGINT, BOOLEAN, col, DATE, fn, Op, STRING, UUID } from "sequelize"; +import { BIGINT, BOOLEAN, col, DATE, fn, Op, STRING, UUID, UUIDV4 } from "sequelize"; import { Role } from "./role.entity"; import { ModelHasRole } from "./model-has-role.entity"; import { Permission } from "./permission.entity"; @@ -37,7 +37,7 @@ export class User extends Model { // index until that is fixed. @AllowNull @Index({ unique: false }) - @Column(UUID) + @Column({ type: UUID, defaultValue: UUIDV4 }) uuid: string | null; @ForeignKey(() => Organisation) diff --git a/libs/database/src/lib/factories/action.factory.ts b/libs/database/src/lib/factories/action.factory.ts index e3c8669f..d733404c 100644 --- a/libs/database/src/lib/factories/action.factory.ts +++ b/libs/database/src/lib/factories/action.factory.ts @@ -4,7 +4,7 @@ import { OrganisationFactory } from "./organisation.factory"; import { ProjectFactory } from "./project.factory"; const defaultAttributesFactory = async () => ({ - uuid: crypto.randomUUID(), + type: "notification", organisationId: OrganisationFactory.associate("id"), projectId: ProjectFactory.associate("id") }); diff --git a/libs/database/src/lib/factories/application.factory.ts b/libs/database/src/lib/factories/application.factory.ts index a39ec929..972717d2 100644 --- a/libs/database/src/lib/factories/application.factory.ts +++ b/libs/database/src/lib/factories/application.factory.ts @@ -4,7 +4,6 @@ import { OrganisationFactory } from "./organisation.factory"; import { FundingProgrammeFactory } from "./funding-programme.factory"; export const ApplicationFactory = FactoryGirl.define(Application, async () => ({ - uuid: crypto.randomUUID(), organisationUuid: OrganisationFactory.associate("uuid"), fundingProgrammeUuid: FundingProgrammeFactory.associate("uuid") })); diff --git a/libs/database/src/lib/factories/delayed-job.factory.ts b/libs/database/src/lib/factories/delayed-job.factory.ts index ad31459a..8091a929 100644 --- a/libs/database/src/lib/factories/delayed-job.factory.ts +++ b/libs/database/src/lib/factories/delayed-job.factory.ts @@ -2,7 +2,6 @@ import { FactoryGirl } from "factory-girl-ts"; import { DelayedJob } from "../entities"; export const DelayedJobFactory = FactoryGirl.define(DelayedJob, async () => ({ - uuid: crypto.randomUUID(), status: "succeeded", statusCode: 200, payload: { data: "test" }, diff --git a/libs/database/src/lib/factories/demographic-entry.factory.ts b/libs/database/src/lib/factories/demographic-entry.factory.ts index fbe8d946..dbcb6fec 100644 --- a/libs/database/src/lib/factories/demographic-entry.factory.ts +++ b/libs/database/src/lib/factories/demographic-entry.factory.ts @@ -20,7 +20,6 @@ const NAMES: Record = { export const DemographicEntryFactory = FactoryGirl.define(DemographicEntry, async () => { const type = faker.helpers.arrayElement(TYPES); return { - uuid: crypto.randomUUID(), demographicId: DemographicFactory.forProjectReportWorkday.associate("id"), type, subtype: faker.helpers.arrayElement(SUBTYPES[type] ?? [null]), diff --git a/libs/database/src/lib/factories/demographic.factory.ts b/libs/database/src/lib/factories/demographic.factory.ts index cefd7e16..2ddd50db 100644 --- a/libs/database/src/lib/factories/demographic.factory.ts +++ b/libs/database/src/lib/factories/demographic.factory.ts @@ -16,7 +16,6 @@ import { ProjectPitchFactory } from "./project-pitch.factory"; import { ProjectFactory } from "./project.factory"; const defaultAttributesFactory = async () => ({ - uuid: crypto.randomUUID(), description: null, hidden: false }); diff --git a/libs/database/src/lib/factories/form-submission.factory.ts b/libs/database/src/lib/factories/form-submission.factory.ts index dfb3b2b4..4baf4211 100644 --- a/libs/database/src/lib/factories/form-submission.factory.ts +++ b/libs/database/src/lib/factories/form-submission.factory.ts @@ -5,7 +5,6 @@ import { ApplicationFactory } from "./application.factory"; import { FORM_SUBMISSION_STATUSES } from "../constants/status"; export const FormSubmissionFactory = FactoryGirl.define(FormSubmission, async () => ({ - uuid: crypto.randomUUID(), applicationId: ApplicationFactory.associate("id"), name: faker.animal.petName(), status: faker.helpers.arrayElement(FORM_SUBMISSION_STATUSES) diff --git a/libs/database/src/lib/factories/funding-programme.factory.ts b/libs/database/src/lib/factories/funding-programme.factory.ts index e2243b5a..30875d70 100644 --- a/libs/database/src/lib/factories/funding-programme.factory.ts +++ b/libs/database/src/lib/factories/funding-programme.factory.ts @@ -3,6 +3,5 @@ import { FundingProgramme } from "../entities"; import { faker } from "@faker-js/faker"; export const FundingProgrammeFactory = FactoryGirl.define(FundingProgramme, async () => ({ - uuid: crypto.randomUUID(), name: faker.animal.petName() })); diff --git a/libs/database/src/lib/factories/i18n-item.factory.ts b/libs/database/src/lib/factories/i18n-item.factory.ts new file mode 100644 index 00000000..a182fc5e --- /dev/null +++ b/libs/database/src/lib/factories/i18n-item.factory.ts @@ -0,0 +1,9 @@ +import { FactoryGirl } from "factory-girl-ts"; +import { I18nItem } from "../entities"; +import { faker } from "@faker-js/faker"; + +export const I18nItemFactory = FactoryGirl.define(I18nItem, async () => ({ + status: "translated", + type: "short", + shortValue: faker.lorem.word(4) +})); diff --git a/libs/database/src/lib/factories/i18n-translation.factory.ts b/libs/database/src/lib/factories/i18n-translation.factory.ts new file mode 100644 index 00000000..b36d4665 --- /dev/null +++ b/libs/database/src/lib/factories/i18n-translation.factory.ts @@ -0,0 +1,11 @@ +import { FactoryGirl } from "factory-girl-ts"; +import { I18nTranslation } from "../entities"; +import { faker } from "@faker-js/faker"; +import { I18nItemFactory } from "./i18n-item.factory"; + +export const I18nTranslationFactory = FactoryGirl.define(I18nTranslation, async () => ({ + i18nItemId: I18nItemFactory.associate("id"), + language: "en-US", + shortValue: faker.lorem.word(4), + longValue: faker.lorem.sentence() +})); diff --git a/libs/database/src/lib/factories/index.ts b/libs/database/src/lib/factories/index.ts index 96f82d0b..0837714d 100644 --- a/libs/database/src/lib/factories/index.ts +++ b/libs/database/src/lib/factories/index.ts @@ -4,6 +4,7 @@ export * from "./demographic.factory"; export * from "./demographic-entry.factory"; export * from "./form-submission.factory"; export * from "./funding-programme.factory"; +export * from "./i18n-item.factory"; export * from "./indicator-output-field-monitoring.factory"; export * from "./indicator-output-hectares.factory"; export * from "./indicator-output-msu-carbon.factory"; @@ -27,5 +28,6 @@ export * from "./site-polygon.factory"; export * from "./site-report.factory"; export * from "./tree-species.factory"; export * from "./tree-species-research.factory"; +export * from "./update-request.factory"; export * from "./user.factory"; export * from "./task.factory"; diff --git a/libs/database/src/lib/factories/localization-key.factory.ts b/libs/database/src/lib/factories/localization-key.factory.ts index 2bb45d6b..73fdacad 100644 --- a/libs/database/src/lib/factories/localization-key.factory.ts +++ b/libs/database/src/lib/factories/localization-key.factory.ts @@ -1,9 +1,10 @@ import { FactoryGirl } from "factory-girl-ts"; import { faker } from "@faker-js/faker"; import { LocalizationKey } from "../entities"; +import { I18nItemFactory } from "./i18n-item.factory"; export const LocalizationKeyFactory = FactoryGirl.define(LocalizationKey, async () => ({ key: faker.lorem.word(), value: faker.lorem.sentence(), - valueId: faker.number.int({ min: 1, max: 1000 }) + valueId: I18nItemFactory.associate("id") })); diff --git a/libs/database/src/lib/factories/media.factory.ts b/libs/database/src/lib/factories/media.factory.ts index 1ccb47ae..f6470b38 100644 --- a/libs/database/src/lib/factories/media.factory.ts +++ b/libs/database/src/lib/factories/media.factory.ts @@ -4,7 +4,6 @@ import { ProjectFactory } from "./project.factory"; import { faker } from "@faker-js/faker"; const defaultAttributesFactory = async () => ({ - uuid: crypto.randomUUID(), collectionName: faker.lorem.words(1), name: faker.lorem.words(2), fileName: `${faker.lorem.words(1)}.jpg`, diff --git a/libs/database/src/lib/factories/nursery-report.factory.ts b/libs/database/src/lib/factories/nursery-report.factory.ts index bcb803bf..061cca36 100644 --- a/libs/database/src/lib/factories/nursery-report.factory.ts +++ b/libs/database/src/lib/factories/nursery-report.factory.ts @@ -3,19 +3,17 @@ import { NurseryReport } from "../entities"; import { faker } from "@faker-js/faker"; import { DateTime } from "luxon"; import { NurseryFactory } from "./nursery.factory"; -import { REPORT_STATUSES, UPDATE_REQUEST_STATUSES } from "../constants/status"; import { TaskFactory } from "./task.factory"; +import { NO_UPDATE } from "../constants/status"; export const NurseryReportFactory = FactoryGirl.define(NurseryReport, async () => { const dueAt = faker.date.past({ years: 2 }); dueAt.setMilliseconds(0); return { - uuid: crypto.randomUUID(), nurseryId: NurseryFactory.associate("id"), taskId: TaskFactory.associate("id"), dueAt, submittedAt: faker.date.between({ from: dueAt, to: DateTime.fromJSDate(dueAt).plus({ days: 14 }).toJSDate() }), - status: faker.helpers.arrayElement(REPORT_STATUSES), - updateRequestStatus: faker.helpers.arrayElement(UPDATE_REQUEST_STATUSES) + updateRequestStatus: NO_UPDATE }; }); diff --git a/libs/database/src/lib/factories/nursery.factory.ts b/libs/database/src/lib/factories/nursery.factory.ts index 58eaeb89..6eb426a9 100644 --- a/libs/database/src/lib/factories/nursery.factory.ts +++ b/libs/database/src/lib/factories/nursery.factory.ts @@ -2,12 +2,10 @@ import { FactoryGirl } from "factory-girl-ts"; import { Nursery } from "../entities"; import { ProjectFactory } from "./project.factory"; import { faker } from "@faker-js/faker"; -import { ENTITY_STATUSES, UPDATE_REQUEST_STATUSES } from "../constants/status"; +import { UPDATE_REQUEST_STATUSES } from "../constants/status"; export const NurseryFactory = FactoryGirl.define(Nursery, async () => ({ - uuid: crypto.randomUUID(), projectId: ProjectFactory.associate("id"), name: faker.animal.petName(), - status: faker.helpers.arrayElement(ENTITY_STATUSES), updateRequestStatus: faker.helpers.arrayElement(UPDATE_REQUEST_STATUSES) })); diff --git a/libs/database/src/lib/factories/organisation.factory.ts b/libs/database/src/lib/factories/organisation.factory.ts index adf0beeb..103ee4b7 100644 --- a/libs/database/src/lib/factories/organisation.factory.ts +++ b/libs/database/src/lib/factories/organisation.factory.ts @@ -2,10 +2,19 @@ import { Organisation } from "../entities"; import { FactoryGirl } from "factory-girl-ts"; import { faker } from "@faker-js/faker"; import { ORGANISATION_STATUSES } from "../constants/status"; +import { fakerCountries, fakerStates } from "../util/gadm-mock-data"; -export const OrganisationFactory = FactoryGirl.define(Organisation, async () => ({ - uuid: crypto.randomUUID(), - status: faker.helpers.arrayElement(ORGANISATION_STATUSES), - type: "non-profit-organisation", - name: faker.company.name() -})); +export const OrganisationFactory = FactoryGirl.define(Organisation, async () => { + const countries = fakerCountries(2); + const hqCountry = faker.helpers.arrayElement(countries); + const states = fakerStates(countries, 5); + + return { + status: faker.helpers.arrayElement(ORGANISATION_STATUSES), + type: "non-profit-organisation", + name: faker.company.name(), + hqCountry, + countries, + states + }; +}); diff --git a/libs/database/src/lib/factories/polygon-geometry.factory.ts b/libs/database/src/lib/factories/polygon-geometry.factory.ts index 268fe3c9..e94c9d06 100644 --- a/libs/database/src/lib/factories/polygon-geometry.factory.ts +++ b/libs/database/src/lib/factories/polygon-geometry.factory.ts @@ -16,7 +16,6 @@ export const POLYGON = { }; export const PolygonGeometryFactory = FactoryGirl.define(PolygonGeometry, async () => ({ - uuid: crypto.randomUUID(), polygon: POLYGON, createdBy: UserFactory.associate("id") })); diff --git a/libs/database/src/lib/factories/project-pitch.factory.ts b/libs/database/src/lib/factories/project-pitch.factory.ts index 1e85a690..71f0806b 100644 --- a/libs/database/src/lib/factories/project-pitch.factory.ts +++ b/libs/database/src/lib/factories/project-pitch.factory.ts @@ -1,6 +1,12 @@ import { FactoryGirl } from "factory-girl-ts"; import { ProjectPitch } from "../entities"; +import { fakerCountries, fakerStates } from "../util/gadm-mock-data"; -export const ProjectPitchFactory = FactoryGirl.define(ProjectPitch, async () => ({ - uuid: crypto.randomUUID() -})); +export const ProjectPitchFactory = FactoryGirl.define(ProjectPitch, async () => { + const projectCountry = fakerCountries()[0]; + + return { + projectCountry, + states: fakerStates([projectCountry], 3) + }; +}); diff --git a/libs/database/src/lib/factories/project-report.factory.ts b/libs/database/src/lib/factories/project-report.factory.ts index 79d2677d..b7c4c43d 100644 --- a/libs/database/src/lib/factories/project-report.factory.ts +++ b/libs/database/src/lib/factories/project-report.factory.ts @@ -3,7 +3,7 @@ import { ProjectReport } from "../entities"; import { faker } from "@faker-js/faker"; import { ProjectFactory } from "./project.factory"; import { DateTime } from "luxon"; -import { REPORT_STATUSES, UPDATE_REQUEST_STATUSES } from "../constants/status"; +import { NO_UPDATE } from "../constants/status"; import { FRAMEWORK_KEYS } from "../constants/framework"; import { TaskFactory } from "./task.factory"; @@ -11,13 +11,11 @@ export const ProjectReportFactory = FactoryGirl.define(ProjectReport, async () = const dueAt = faker.date.past({ years: 2 }); dueAt.setMilliseconds(0); return { - uuid: crypto.randomUUID(), projectId: ProjectFactory.associate("id"), taskId: TaskFactory.associate("id"), frameworkKey: faker.helpers.arrayElement(FRAMEWORK_KEYS), dueAt, submittedAt: faker.date.between({ from: dueAt, to: DateTime.fromJSDate(dueAt).plus({ days: 14 }).toJSDate() }), - status: faker.helpers.arrayElement(REPORT_STATUSES), - updateRequestStatus: faker.helpers.arrayElement(UPDATE_REQUEST_STATUSES) + updateRequestStatus: NO_UPDATE }; }); diff --git a/libs/database/src/lib/factories/project.factory.ts b/libs/database/src/lib/factories/project.factory.ts index 6311f914..4bf70e09 100644 --- a/libs/database/src/lib/factories/project.factory.ts +++ b/libs/database/src/lib/factories/project.factory.ts @@ -1,21 +1,25 @@ import { FactoryGirl } from "factory-girl-ts"; import { Project } from "../entities"; import { faker } from "@faker-js/faker"; -import { ENTITY_STATUSES, UPDATE_REQUEST_STATUSES } from "../constants/status"; +import { UPDATE_REQUEST_STATUSES } from "../constants/status"; import { ApplicationFactory } from "./application.factory"; import { OrganisationFactory } from "./organisation.factory"; import { FRAMEWORK_KEYS } from "../constants/framework"; +import { fakerCountries, fakerStates } from "../util/gadm-mock-data"; const CONTINENTS = ["africa", "australia", "south-america", "asia", "north-america"]; -export const ProjectFactory = FactoryGirl.define(Project, async () => ({ - uuid: crypto.randomUUID(), - name: faker.animal.petName(), - frameworkKey: faker.helpers.arrayElement(FRAMEWORK_KEYS), - status: faker.helpers.arrayElement(ENTITY_STATUSES), - updateRequestStatus: faker.helpers.arrayElement(UPDATE_REQUEST_STATUSES), - applicationId: ApplicationFactory.associate("id"), - organisationId: OrganisationFactory.associate("id"), - continent: faker.helpers.arrayElement(CONTINENTS), - survivalRate: faker.number.int({ min: 20, max: 80 }) -})); +export const ProjectFactory = FactoryGirl.define(Project, async () => { + const country = fakerCountries()[0]; + return { + name: faker.animal.petName(), + frameworkKey: faker.helpers.arrayElement(FRAMEWORK_KEYS), + updateRequestStatus: faker.helpers.arrayElement(UPDATE_REQUEST_STATUSES), + applicationId: ApplicationFactory.associate("id"), + organisationId: OrganisationFactory.associate("id"), + continent: faker.helpers.arrayElement(CONTINENTS), + country, + states: fakerStates([country], 3), + survivalRate: faker.number.int({ min: 20, max: 80 }) + }; +}); diff --git a/libs/database/src/lib/factories/seeding.factory.ts b/libs/database/src/lib/factories/seeding.factory.ts index f354c852..8f78bc20 100644 --- a/libs/database/src/lib/factories/seeding.factory.ts +++ b/libs/database/src/lib/factories/seeding.factory.ts @@ -5,7 +5,6 @@ import { SiteReportFactory } from "./site-report.factory"; import { SiteFactory } from "./site.factory"; const defaultAttributesFactory = async () => ({ - uuid: crypto.randomUUID(), name: faker.lorem.words(2), amount: faker.number.int({ min: 10, max: 1000 }), seedsInSample: faker.number.int({ min: 10, max: 1000 }), diff --git a/libs/database/src/lib/factories/site-report.factory.ts b/libs/database/src/lib/factories/site-report.factory.ts index d4abcb28..1822dbb3 100644 --- a/libs/database/src/lib/factories/site-report.factory.ts +++ b/libs/database/src/lib/factories/site-report.factory.ts @@ -3,20 +3,18 @@ import { FactoryGirl } from "factory-girl-ts"; import { SiteFactory } from "./site.factory"; import { faker } from "@faker-js/faker"; import { DateTime } from "luxon"; -import { REPORT_STATUSES, UPDATE_REQUEST_STATUSES } from "../constants/status"; +import { NO_UPDATE } from "../constants/status"; import { TaskFactory } from "./task.factory"; export const SiteReportFactory = FactoryGirl.define(SiteReport, async () => { const dueAt = faker.date.past({ years: 2 }); dueAt.setMilliseconds(0); return { - uuid: crypto.randomUUID(), siteId: SiteFactory.associate("id"), taskId: TaskFactory.associate("id"), dueAt, submittedAt: faker.date.between({ from: dueAt, to: DateTime.fromJSDate(dueAt).plus({ days: 14 }).toJSDate() }), - status: faker.helpers.arrayElement(REPORT_STATUSES), - updateRequestStatus: faker.helpers.arrayElement(UPDATE_REQUEST_STATUSES), + updateRequestStatus: NO_UPDATE, numTreesRegenerating: faker.number.int({ min: 10, max: 500 }), workdaysPaid: faker.number.int({ min: 10, max: 50 }), workdaysVolunteer: faker.number.int({ min: 10, max: 50 }) diff --git a/libs/database/src/lib/factories/site.factory.ts b/libs/database/src/lib/factories/site.factory.ts index 84579744..3036035b 100644 --- a/libs/database/src/lib/factories/site.factory.ts +++ b/libs/database/src/lib/factories/site.factory.ts @@ -2,14 +2,12 @@ import { Site } from "../entities"; import { FactoryGirl } from "factory-girl-ts"; import { ProjectFactory } from "./project.factory"; import { faker } from "@faker-js/faker"; -import { SITE_STATUSES, UPDATE_REQUEST_STATUSES } from "../constants/status"; +import { UPDATE_REQUEST_STATUSES } from "../constants/status"; import { SITING_STRATEGIES } from "../constants/entity-selects"; export const SiteFactory = FactoryGirl.define(Site, async () => ({ - uuid: crypto.randomUUID(), projectId: ProjectFactory.associate("id"), name: faker.animal.petName(), - status: faker.helpers.arrayElement(SITE_STATUSES), updateRequestStatus: faker.helpers.arrayElement(UPDATE_REQUEST_STATUSES), sitingStrategy: faker.helpers.arrayElement(SITING_STRATEGIES), descriptionSitingStrategy: faker.lorem.paragraph() diff --git a/libs/database/src/lib/factories/task.factory.ts b/libs/database/src/lib/factories/task.factory.ts index dd97a7fb..2d816be1 100644 --- a/libs/database/src/lib/factories/task.factory.ts +++ b/libs/database/src/lib/factories/task.factory.ts @@ -1,8 +1,4 @@ import { FactoryGirl } from "factory-girl-ts"; import { Task } from "../entities"; -export const TaskFactory = FactoryGirl.define(Task, async () => { - return { - uuid: crypto.randomUUID() - }; -}); +export const TaskFactory = FactoryGirl.define(Task, async () => ({})); diff --git a/libs/database/src/lib/factories/tree-species.factory.ts b/libs/database/src/lib/factories/tree-species.factory.ts index 2bc46dd3..02c9b501 100644 --- a/libs/database/src/lib/factories/tree-species.factory.ts +++ b/libs/database/src/lib/factories/tree-species.factory.ts @@ -9,7 +9,6 @@ import { ProjectReportFactory } from "./project-report.factory"; import { NurseryFactory } from "./nursery.factory"; const defaultAttributesFactory = async () => ({ - uuid: crypto.randomUUID(), name: faker.lorem.words(2), taxonId: null, amount: faker.number.int({ min: 10, max: 1000 }), diff --git a/libs/database/src/lib/factories/update-request.factory.ts b/libs/database/src/lib/factories/update-request.factory.ts new file mode 100644 index 00000000..0290d9ba --- /dev/null +++ b/libs/database/src/lib/factories/update-request.factory.ts @@ -0,0 +1,15 @@ +import { FactoryGirl } from "factory-girl-ts"; +import { Project, UpdateRequest } from "../entities"; +import { ProjectFactory } from "./project.factory"; + +const defaultAttributesFactory = async () => ({ + status: "awaiting-approval" +}); + +export const UpdateRequestFactory = { + forProject: FactoryGirl.define(UpdateRequest, async () => ({ + ...(await defaultAttributesFactory()), + updateRequestableType: Project.LARAVEL_TYPE, + updateRequestableId: ProjectFactory.associate("id") + })) +}; diff --git a/libs/database/src/lib/factories/user.factory.ts b/libs/database/src/lib/factories/user.factory.ts index 9e99f534..c0640208 100644 --- a/libs/database/src/lib/factories/user.factory.ts +++ b/libs/database/src/lib/factories/user.factory.ts @@ -5,7 +5,7 @@ import { User } from "../entities"; // TODO: generate correctly hashed passwords. This will be easily accomplished once user signup // has been implemented in this codebase. export const UserFactory = FactoryGirl.define(User, async () => ({ - uuid: crypto.randomUUID(), + locale: "en-US", firstName: faker.person.firstName(), lastName: faker.person.lastName(), emailAddress: await generateUniqueEmail(), @@ -15,7 +15,7 @@ export const UserFactory = FactoryGirl.define(User, async () => ({ async function generateUniqueEmail() { let emailAddress = faker.internet.email(); - while ((await User.findOne({ where: { emailAddress } })) != null) { + while ((await User.count({ where: { emailAddress } })) !== 0) { emailAddress = faker.internet.email(); } diff --git a/libs/database/src/lib/sequelize-config.service.ts b/libs/database/src/lib/sequelize-config.service.ts index 543e1004..87f3d9d7 100644 --- a/libs/database/src/lib/sequelize-config.service.ts +++ b/libs/database/src/lib/sequelize-config.service.ts @@ -2,6 +2,20 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import * as Entities from "./entities"; import { SequelizeModuleOptions, SequelizeOptionsFactory } from "@nestjs/sequelize"; +import { Model } from "sequelize-typescript"; +import { getStateMachine, getStateMachineProperties } from "./util/model-column-state-machine"; + +export const SEQUELIZE_GLOBAL_HOOKS = { + afterSave: function (model: Model) { + // After any model saves, check if we have a state machine defined on one or more of its + // columns, and if so, call afterSave on the state machine instance for the possible + // processing of afterTransitionHooks. See StateMachineColumn decorator in + // model-column-state-machine.ts + for (const key of getStateMachineProperties(model)) { + getStateMachine(model, key)?.afterSave(); + } + } +}; @Injectable() export class SequelizeConfigService implements SequelizeOptionsFactory { @@ -18,6 +32,7 @@ export class SequelizeConfigService implements SequelizeOptionsFactory { database: this.configService.get("DB_DATABASE"), synchronize: false, models: Object.values(Entities), + hooks: SEQUELIZE_GLOBAL_HOOKS, logging: sql => logger.log(sql) }; } diff --git a/libs/database/src/lib/types/util.ts b/libs/database/src/lib/types/util.ts index 24bfc93f..5f982e60 100644 --- a/libs/database/src/lib/types/util.ts +++ b/libs/database/src/lib/types/util.ts @@ -1,3 +1,14 @@ -import { Model } from "sequelize-typescript"; +import { Model, ModelCtor } from "sequelize-typescript"; -export type UuidModel = Model & { uuid: string }; +export type UuidModel = Model & { uuid: string }; + +export type LaravelModelCtor = ModelCtor & { LARAVEL_TYPE: string }; +export type LaravelModel = InstanceType; + +export const laravelType = (model: LaravelModel) => (model.constructor as LaravelModelCtor).LARAVEL_TYPE; + +export type StatusModel = Model & { status: string }; + +export type FeedbackModel = Model & { feedback?: string | null; feedbackFields?: string[] | null }; + +export type StatusUpdateModel = LaravelModel & StatusModel & FeedbackModel; diff --git a/libs/database/src/lib/util/gadm-mock-data.ts b/libs/database/src/lib/util/gadm-mock-data.ts new file mode 100644 index 00000000..2ac85df3 --- /dev/null +++ b/libs/database/src/lib/util/gadm-mock-data.ts @@ -0,0 +1,151 @@ +import { faker } from "@faker-js/faker"; +import { merge } from "lodash"; + +export const COUNTRIES = { + ESP: "Spain", + USA: "United States", + MEX: "México", + CHL: "Chile" +} as const; + +type Country = keyof typeof COUNTRIES; + +export const fakerCountries = (num = 1) => faker.helpers.uniqueArray(Object.keys(COUNTRIES), num) as Country[]; + +export const gadmLevel0Mock = async () => Object.entries(COUNTRIES).map(([iso, name]) => ({ iso, name })); + +export const STATES = { + ESP: { + "ESP.4_1": "Castilla-La Mancha", + "ESP.5_1": "Castilla y León", + "ESP.6_1": "Cataluña", + "ESP.12_1": "Galicia", + "ESP.1_1": "Andalucía", + "ESP.2_1": "Aragón", + "ESP.3_1": "Cantabria", + "ESP.7_1": "Ceuta y Melilla", + "ESP.8_1": "Comunidad de Madrid", + "ESP.9_1": "Comunidad Foral de Navarra", + "ESP.10_1": "Comunidad Valenciana", + "ESP.11_1": "Extremadura", + "ESP.13_1": "Islas Baleares", + "ESP.14_1": "Islas Canarias", + "ESP.15_1": "La Rioja", + "ESP.16_1": "País Vasco", + "ESP.17_1": "Principado de Asturias", + "ESP.18_1": "Región de Murcia" + }, + USA: { + "USA.1_1": "Alabama", + "USA.2_1": "Alaska", + "USA.41_1": "South Carolina", + "USA.42_1": "South Dakota", + "USA.43_1": "Tennessee", + "USA.44_1": "Texas", + "USA.45_1": "Utah", + "USA.46_1": "Vermont", + "USA.47_1": "Virginia", + "USA.48_1": "Washington", + "USA.49_1": "West Virginia", + "USA.50_1": "Wisconsin", + "USA.36_1": "Ohio", + "USA.3_1": "Arizona", + "USA.4_1": "Arkansas", + "USA.5_1": "California", + "USA.9_1": "District of Columbia", + "USA.10_1": "Florida", + "USA.11_1": "Georgia", + "USA.12_1": "Hawaii", + "USA.13_1": "Idaho", + "USA.14_1": "Illinois", + "USA.15_1": "Indiana", + "USA.16_1": "Iowa", + "USA.17_1": "Kansas", + "USA.18_1": "Kentucky", + "USA.19_1": "Louisiana", + "USA.20_1": "Maine", + "USA.21_1": "Maryland", + "USA.22_1": "Massachusetts", + "USA.23_1": "Michigan", + "USA.24_1": "Minnesota", + "USA.25_1": "Mississippi", + "USA.26_1": "Missouri", + "USA.27_1": "Montana", + "USA.28_1": "Nebraska", + "USA.29_1": "Nevada", + "USA.30_1": "New Hampshire", + "USA.31_1": "New Jersey", + "USA.32_1": "New Mexico", + "USA.33_1": "New York", + "USA.34_1": "North Carolina", + "USA.35_1": "North Dakota", + "USA.37_1": "Oklahoma", + "USA.38_1": "Oregon", + "USA.39_1": "Pennsylvania", + "USA.40_1": "Rhode Island", + "USA.6_1": "Colorado", + "USA.7_1": "Connecticut", + "USA.8_1": "Delaware", + "USA.51_1": "Wyoming" + }, + MEX: { + "MEX.5_1": "Chiapas", + "MEX.26_1": "Sonora", + "MEX.27_1": "Tabasco", + "MEX.28_1": "Tamaulipas", + "MEX.29_1": "Tlaxcala", + "MEX.30_1": "Veracruz", + "MEX.31_1": "Yucatán", + "MEX.32_1": "Zacatecas", + "MEX.18_1": "Nayarit", + "MEX.19_1": "Nuevo León", + "MEX.20_1": "Oaxaca", + "MEX.21_1": "Puebla", + "MEX.22_1": "Querétaro", + "MEX.11_1": "Guanajuato", + "MEX.12_1": "Guerrero", + "MEX.13_1": "Hidalgo", + "MEX.14_1": "Jalisco", + "MEX.15_1": "México", + "MEX.16_1": "Michoacán", + "MEX.17_1": "Morelos", + "MEX.23_1": "Quintana Roo", + "MEX.24_1": "San Luis Potosí", + "MEX.25_1": "Sinaloa", + "MEX.4_1": "Campeche", + "MEX.6_1": "Chihuahua", + "MEX.7_1": "Coahuila", + "MEX.8_1": "Colima", + "MEX.9_1": "Distrito Federal", + "MEX.10_1": "Durango", + "MEX.1_1": "Aguascalientes", + "MEX.3_1": "Baja California", + "MEX.2_1": "Baja California Sur" + }, + CHL: { + "CHL.6_1": "Bío-Bío", + "CHL.7_1": "Coquimbo", + "CHL.8_1": "Libertador General Bernardo O'Hi", + "CHL.2_1": "Antofagasta", + "CHL.3_1": "Araucanía", + "CHL.4_1": "Arica y Parinacota", + "CHL.5_1": "Atacama", + "CHL.1_1": "Aysén del General Ibañez del Cam", + "CHL.9_1": "Los Lagos", + "CHL.10_1": "Los Ríos", + "CHL.11_1": "Magallanes y Antártica Chilena", + "CHL.12_1": "Maule", + "CHL.13_1": "Ñuble", + "CHL.14_1": "Santiago Metropolitan", + "CHL.15_1": "Tarapacá", + "CHL.16_1": "Valparaíso" + } +} as const; + +export const fakerStates = (countries: Country[], num = 1) => { + const options = Object.keys(merge({}, ...countries.map(country => STATES[country]))); + return faker.helpers.uniqueArray(options, num); +}; + +export const gadmLevel1Mock = async (level0: Country) => + Object.entries(STATES[level0]).map(([id, name]) => ({ id, name })); diff --git a/libs/database/src/lib/util/model-column-state-machine.spec.ts b/libs/database/src/lib/util/model-column-state-machine.spec.ts new file mode 100644 index 00000000..e9f19c7b --- /dev/null +++ b/libs/database/src/lib/util/model-column-state-machine.spec.ts @@ -0,0 +1,111 @@ +import { Model, Sequelize, Table } from "sequelize-typescript"; +import { StateMachineColumn, StateMachineException, States, transitions } from "./model-column-state-machine"; +import { SEQUELIZE_GLOBAL_HOOKS } from "../sequelize-config.service"; + +const hook = jest.fn(() => undefined); +const transitionValid = jest.fn(() => true); + +type StubStatus = "first" | "second" | "third" | "final"; +const StubStates: States = { + default: "first", + + transitions: transitions() + .from("first", () => ["second", "final"]) + .from("second", () => ["third", "final"]) + .from("third", () => ["final"]).transitions, + + transitionValidForModel: transitionValid, + + afterTransitionHooks: { + final: hook + } +}; + +@Table({}) +class StubModel extends Model { + @StateMachineColumn(StubStates) + status: StubStatus; +} + +const sequelize = new Sequelize({ + dialect: "sqlite", + database: "test_db", + storage: ":memory:", + logging: false, + models: [StubModel], + hooks: SEQUELIZE_GLOBAL_HOOKS +}); + +async function createModel(status?: StubStatus) { + const model = new StubModel(); + if (status != null) model.status = status; + await model.save(); + return model; +} + +describe("ModelColumnStateMachine", () => { + beforeAll(async () => { + await sequelize.sync(); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + transitionValid.mockClear(); + hook.mockClear(); + }); + + it("should return the current status string from the model attribute getter", async () => { + expect((await createModel()).status).toBe("first"); + expect((await createModel("second")).status).toBe("second"); + }); + + it("should allow a new model to start with any state", async () => { + expect(() => (new StubModel().status = "third")).not.toThrow(StateMachineException); + }); + + it("should throw when the transition is not defined", async () => { + const model = new StubModel(); + model.isNewRecord = false; + expect(() => (model.status = "third")).toThrow(StateMachineException); + }); + + it("should throw if the current state is not defined in the SM", async () => { + const model = await createModel("foo" as StubStatus); + expect(() => (model.status = "first")).toThrow(StateMachineException); + expect(() => (model.status = "second")).toThrow(StateMachineException); + expect(() => (model.status = "third")).toThrow(StateMachineException); + expect(() => (model.status = "final")).toThrow(StateMachineException); + }); + + it("should serialize to the string value of the state", async () => { + let result = JSON.parse(JSON.stringify(await createModel())); + expect(result.status).toBe("first"); + result = JSON.parse(JSON.stringify(await createModel("final"))); + expect(result.status).toBe("final"); + }); + + it("should allow a transition to the current status", async () => { + const model = await createModel("second"); + expect(() => (model.status = "second")).not.toThrow(StateMachineException); + }); + + it("should check validations with defined validator", async () => { + const model = await createModel(); + model.status = "second"; + expect(transitionValid).toHaveBeenCalledWith("first", "second", model); + + transitionValid.mockClear(); + transitionValid.mockReturnValueOnce(false); + expect(() => (model.status = "third")).toThrow(StateMachineException); + expect(transitionValid).toHaveBeenCalledWith("second", "third", model); + }); + + it("should process after save hooks", async () => { + const model = await createModel("second"); + await model.update({ status: "third" }); + expect(hook).not.toHaveBeenCalled(); + + await model.update({ status: "final" }); + expect(hook).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/database/src/lib/util/model-column-state-machine.ts b/libs/database/src/lib/util/model-column-state-machine.ts new file mode 100644 index 00000000..84418b9f --- /dev/null +++ b/libs/database/src/lib/util/model-column-state-machine.ts @@ -0,0 +1,160 @@ +import { Column, Model } from "sequelize-typescript"; +import { Attributes, STRING } from "sequelize"; +import { HttpException, HttpStatus } from "@nestjs/common"; + +type Transitions = Partial>; + +export type States = { + default: S; + transitions: Transitions; + + /** + * If specified, this function can be used to perform extra validations for a transition within + * the context of the model it's attached to. If the function is defined and returns false, the + * transition will be rejected with a StateMachineError. + * + * @param from The "from" state for the transition + * @param to The "to" state for the transition + * @param model The model the transition will be applied to. + */ + transitionValidForModel?: (from: S, to: S, model: M) => boolean; + + /** + * Specify hooks that should be fired when a given state is transitioned to. The callback + * will be executed after the model has been saved. + * + * Note: This means that if multiple transitions are executed on a given model column without + * a save to the database in between, this hook will only fire after the _last_ transition. To + * ensure the hook fires for every transition, make sure to save the model state between + * successive transitions. + */ + afterTransitionHooks?: Partial void>>; +}; + +/** + * A simple builder mechanism to make it more readable and concise to extend one set of states for another set of states. + */ +class TransitionBuilder { + constructor(public transitions: Transitions = {}) {} + + from(from: S, to: (current: S[]) => S[]) { + this.transitions[from] = to(this.transitions[from] ?? []); + return this; + } +} + +export const transitions = (transitions: Transitions = {}) => + new TransitionBuilder(transitions); + +// Extends HttpException so these errors bubble up to the API consumer +export class StateMachineException extends HttpException { + constructor(message: string) { + super(message, HttpStatus.BAD_REQUEST); + } +} + +export type StateMachineModel = M & { + _stateMachines?: Record>; +}; + +function ensureStateMachine( + model: StateMachineModel, + propertyName: keyof Attributes, + states: States +) { + if (model._stateMachines == null) model._stateMachines = {}; + if (model._stateMachines[propertyName as string] == null) { + model._stateMachines[propertyName as string] = new ModelColumnStateMachine(model, propertyName, states); + } + return model._stateMachines[propertyName as string]; +} + +export function getStateMachine(model: M, propertyName: keyof Attributes) { + return (model as StateMachineModel)._stateMachines?.[propertyName as string]; +} + +const METADATA_KEY = Symbol("model-column-state-machine"); + +export const getStateMachineProperties = (model: M): (keyof Attributes)[] => + Reflect.getMetadata(METADATA_KEY, model) ?? []; + +/** + * Apply to any string column in a database model class to enforce state machine mechanics. + */ +export const StateMachineColumn = + (states: States) => + (target: M, propertyName: string, propertyDescriptor?: PropertyDescriptor) => { + // Will cause the `afterSave` method of the state machine to be called when the model is + // saved. See sequelize-config.service.ts + Reflect.defineMetadata(METADATA_KEY, [...getStateMachineProperties(target), propertyName], target); + + // Define the sequelize column as we need it. To consumers of the model, it will appear to be a + // column of type S. If access to the underlying state machine is needed, use getStateMachine() + Column({ + type: STRING, + defaultValue: states.default, + + get(this: StateMachineModel) { + return ensureStateMachine(this, propertyName as keyof Attributes, states).current; + }, + + set(this: StateMachineModel, value: S) { + ensureStateMachine(this, propertyName as keyof Attributes, states).transitionTo(value); + } + })(target, propertyName, propertyDescriptor); + }; + +export class ModelColumnStateMachine { + constructor( + protected readonly model: M, + protected readonly column: keyof Attributes, + protected readonly states: States + ) {} + + protected fromState?: S; + + get current(): S { + return this.model.getDataValue(this.column) as S; + } + + canBe(from: S, to: S) { + const toStates = this.states.transitions[from]; + if (toStates == null) { + throw new StateMachineException(`Current state is not defined [${from}]`); + } + + return from === to || toStates.includes(to) === true; + } + + transitionTo(to: S) { + this.validateTransition(to); + + this.fromState = this.current; + this.model.setDataValue(this.column, to); + } + + validateTransition(to: S) { + if (this.model.isNewRecord) return; + + if (!this.canBe(this.current, to)) { + throw new StateMachineException(`Transition not valid [from=${this.current}, to=${to}]`); + } + + if ( + this.states.transitionValidForModel != null && + !this.states.transitionValidForModel(this.current, to, this.model) + ) { + throw new StateMachineException( + `Transition not valid for model [from=${this.current}, to=${to}, id=${this.model.id}]` + ); + } + } + + afterSave() { + if (this.fromState == null) return; + + const fromState = this.fromState; + this.fromState = undefined; + this.states.afterTransitionHooks?.[this.current]?.(fromState, this.model); + } +} diff --git a/libs/database/src/lib/util/paginated-query.builder.ts b/libs/database/src/lib/util/paginated-query.builder.ts index 85b7dd6a..83bb1caf 100644 --- a/libs/database/src/lib/util/paginated-query.builder.ts +++ b/libs/database/src/lib/util/paginated-query.builder.ts @@ -77,6 +77,6 @@ export class PaginatedQueryBuilder> { } async paginationTotal() { - return await this.MODEL.count(this.findOptions); + return await this.MODEL.count({ distinct: true, ...this.findOptions }); } } diff --git a/package-lock.json b/package-lock.json index 050ddba8..242be6ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,12 @@ "dependencies": { "@aws-sdk/client-ecs": "^3.766.0", "@casl/ability": "^6.7.3", + "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/bullmq": "^11.0.2", "@nestjs/common": "^11.0.11", "@nestjs/config": "^4.0.1", "@nestjs/core": "^11.0.11", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^11.0.0", "@nestjs/platform-express": "^11.0.11", "@nestjs/schedule": "^5.0.1", @@ -34,6 +36,7 @@ "debug": "^4.4.0", "geojson": "^0.5.0", "hbs": "^4.2.0", + "ioredis": "^5.6.1", "lodash": "^4.17.21", "luxon": "^3.5.0", "mariadb": "^3.4.0", @@ -85,8 +88,10 @@ "husky": "^9.1.7", "jest": "^29.7.0", "jest-environment-node": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "nx": "20.6.1", "prettier": "^2.6.2", + "sqlite3": "^5.1.7", "supertest": "^7.0.0", "ts-jest": "^29.2.6", "ts-node": "10.9.2", @@ -2791,6 +2796,13 @@ "tslib": "2" } }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "optional": true + }, "node_modules/@golevelup/ts-jest": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/@golevelup/ts-jest/-/ts-jest-0.6.2.tgz", @@ -3503,6 +3515,114 @@ "@tybys/wasm-util": "^0.9.0" } }, + "node_modules/@nestjs-modules/ioredis": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@nestjs-modules/ioredis/-/ioredis-2.0.2.tgz", + "integrity": "sha512-8pzSvT8R3XP6p8ZzQmEN8OnY0yWrJ/elFhwQK+PID2zf1SLBkAZ18bDcx3SKQ2atledt0gd9kBeP5xT4MlyS7Q==", + "optionalDependencies": { + "@nestjs/terminus": "10.2.0" + }, + "peerDependencies": { + "@nestjs/common": ">=6.7.0", + "@nestjs/core": ">=6.7.0", + "ioredis": ">=5.0.0" + } + }, + "node_modules/@nestjs-modules/ioredis/node_modules/@nestjs/sequelize": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/sequelize/-/sequelize-10.0.1.tgz", + "integrity": "sha512-ICzmkB0IHLm3x9SBGG8c/T9KLU9CRmtLWtuMsLHwX373HBgiKHd8Wu/5b+FM+rNRh8zWINiTTjKHoOx9kjvSjA==", + "optional": true, + "peer": true, + "dependencies": { + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "sequelize": "^6.3.5", + "sequelize-typescript": "^2.0.0" + } + }, + "node_modules/@nestjs-modules/ioredis/node_modules/@nestjs/terminus": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.2.0.tgz", + "integrity": "sha512-zPs98xvJ4ogEimRQOz8eU90mb7z+W/kd/mL4peOgrJ/VqER+ibN2Cboj65uJZW3XuNhpOqaeYOJte86InJd44A==", + "optional": true, + "dependencies": { + "boxen": "5.1.2", + "check-disk-space": "3.4.0" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@grpc/proto-loader": "*", + "@mikro-orm/core": "*", + "@mikro-orm/nestjs": "*", + "@nestjs/axios": "^1.0.0 || ^2.0.0 || ^3.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "@nestjs/microservices": "^9.0.0 || ^10.0.0", + "@nestjs/mongoose": "^9.0.0 || ^10.0.0", + "@nestjs/sequelize": "^9.0.0 || ^10.0.0", + "@nestjs/typeorm": "^9.0.0 || ^10.0.0", + "@prisma/client": "*", + "mongoose": "*", + "reflect-metadata": "0.1.x", + "rxjs": "7.x", + "sequelize": "*", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@grpc/proto-loader": { + "optional": true + }, + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/nestjs": { + "optional": true + }, + "@nestjs/axios": { + "optional": true + }, + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/mongoose": { + "optional": true + }, + "@nestjs/sequelize": { + "optional": true + }, + "@nestjs/typeorm": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "sequelize": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, + "node_modules/@nestjs-modules/ioredis/node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "optional": true, + "peer": true + }, "node_modules/@nestjs/bull-shared": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.2.tgz", @@ -3530,11 +3650,14 @@ } }, "node_modules/@nestjs/common": { - "version": "11.0.11", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.0.11.tgz", - "integrity": "sha512-b3zYiho5/XGCnLa7W2hHv5ecSBR1huQrXCHu6pxd+g2HY2B7sKP5CXHMv4gHYqpIqu4ClOb7Q4tLKXMp9LyLUg==", + "version": "11.0.20", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.0.20.tgz", + "integrity": "sha512-/GH8NDCczjn6+6RNEtSNAts/nq/wQE8L1qZ9TRjqjNqEsZNE1vpFuRIhmcO2isQZ0xY5rySnpaRdrOAul3gQ3A==", + "license": "MIT", "dependencies": { + "file-type": "20.4.1", "iterare": "1.2.1", + "load-esm": "1.0.2", "tslib": "2.8.1", "uid": "2.0.2" }, @@ -3611,6 +3734,18 @@ } } }, + "node_modules/@nestjs/event-emitter": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz", + "integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==", + "dependencies": { + "eventemitter2": "6.4.9" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/jwt": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.0.tgz", @@ -3866,6 +4001,45 @@ "node": ">= 8" } }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "dev": true, + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@nuxt/opencollective": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", @@ -6610,6 +6784,40 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/@transifex/native": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@transifex/native/-/native-7.1.3.tgz", @@ -7610,6 +7818,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "optional": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -7687,6 +7902,46 @@ "node": ">= 10.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/airtable": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/airtable/-/airtable-0.12.2.tgz", @@ -7860,6 +8115,43 @@ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "dev": true, + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -8267,6 +8559,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -8526,6 +8827,82 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "dev": true, + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "optional": true + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -8658,6 +9035,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -8702,6 +9088,16 @@ "validator": "^13.9.0" } }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", @@ -8814,6 +9210,16 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", @@ -8991,6 +9397,13 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "optional": true + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -9480,6 +9893,21 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -9494,6 +9922,15 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -9566,6 +10003,13 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "optional": true + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -9867,6 +10311,27 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -9913,6 +10378,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/envinfo": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", @@ -9925,6 +10400,13 @@ "node": ">=4" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "optional": true + }, "node_modules/errno": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", @@ -10309,6 +10791,11 @@ "node": ">=6" } }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==" + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -10355,6 +10842,15 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -10603,6 +11099,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -10639,6 +11141,30 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", + "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -11095,6 +11621,36 @@ "node": ">=12" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/fs-monkey": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", @@ -11128,6 +11684,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -11218,6 +11795,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -11428,6 +12011,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -11518,6 +12108,13 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true, + "optional": true + }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -11559,10 +12156,25 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/http-proxy-middleware": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", - "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", "dev": true, "dependencies": { "@types/http-proxy": "^1.17.8", @@ -11616,6 +12228,20 @@ "node": ">=12" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -11625,6 +12251,16 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -11688,7 +12324,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -11808,6 +12443,23 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "optional": true + }, "node_modules/inflection": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", @@ -11831,6 +12483,12 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, "node_modules/interpret": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", @@ -11852,9 +12510,9 @@ } }, "node_modules/ioredis": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.0.tgz", - "integrity": "sha512-tBZlIIWbndeWBWCXWZiqtOF/yxf6yZX3tAlTJ7nfo5jhd6dctNxF7QnYlZLZ1a0o0pDoen7CgZqO+zjNaFbJAg==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", @@ -11874,6 +12532,27 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "optional": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "optional": true + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -12019,6 +12698,13 @@ "node": ">=8" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "optional": true + }, "node_modules/is-network-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz", @@ -12491,6 +13177,25 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, + "node_modules/jest-fetch-mock/node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dev": true, + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -12885,6 +13590,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "optional": true + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -13210,6 +13922,25 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/load-esm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.2.tgz", + "integrity": "sha512-nVAvWk/jeyrWyXEAs84mpQCYccxRqgKY4OznLuJhJCa0XsPSfdOIr2zvBZEj3IHEHbX97jjscKRRV539bW0Gpw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -13401,6 +14132,77 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "dev": true, + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/make-fetch-happen/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "optional": true + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -13584,6 +14386,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.4.7", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.4.7.tgz", @@ -13641,6 +14455,207 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-collect/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "optional": true + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "dev": true, + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-fetch/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-fetch/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "optional": true + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "optional": true + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "optional": true + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "optional": true + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -13652,6 +14667,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "node_modules/module-details-from-path": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", @@ -13846,6 +14867,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -13923,8 +14950,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "optional": true + "dev": true }, "node_modules/node-fetch": { "version": "2.7.0", @@ -13954,6 +14980,31 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "dev": true, + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", @@ -13994,6 +15045,22 @@ "node": ">=6.0.0" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dev": true, + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -14039,6 +15106,23 @@ "node": ">=8" } }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -14383,6 +15467,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-queue": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", @@ -14570,6 +15670,19 @@ "node": ">=8" } }, + "node_modules/peek-readable": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-7.0.0.tgz", + "integrity": "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/pg-connection-string": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", @@ -15371,6 +16484,32 @@ "node": ">=0.10.0" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -15435,6 +16574,43 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "optional": true + }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -15472,6 +16648,16 @@ "dev": true, "optional": true }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -15573,6 +16759,30 @@ "node": ">=0.10.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -16812,6 +18022,13 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "optional": true + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -16941,6 +18158,51 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -16956,6 +18218,17 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -16976,6 +18249,36 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/socks": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "dev": true, + "optional": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "dev": true, + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -17095,6 +18398,30 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -17108,6 +18435,39 @@ "resolved": "https://registry.npmjs.org/ssm-session/-/ssm-session-1.0.6.tgz", "integrity": "sha512-1qUuh1Pw5dR6l6ldZWeNsafHSDoqU8UltyQfJUGDrl7xCsWMlVW+HcygJ1MCMYprxw1vjmhph16QXdxRi56Dig==" }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "dev": true, + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ssri/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ssri/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "optional": true + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -17269,6 +18629,23 @@ } ] }, + "node_modules/strtok3": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.2.2.tgz", + "integrity": "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", @@ -17518,6 +18895,41 @@ "node": ">=6" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "dev": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -17548,6 +18960,33 @@ "node": ">= 6" } }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/terser": { "version": "5.39.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", @@ -17765,6 +19204,23 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.0.tgz", + "integrity": "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/toposort-class": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", @@ -17956,6 +19412,18 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -18048,6 +19516,18 @@ "node": ">=8" } }, + "node_modules/uint8array-extras": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", + "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -18105,6 +19585,26 @@ "node": ">= 0.8.0" } }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "dev": true, + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -19122,6 +20622,16 @@ "node": ">= 8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", diff --git a/package.json b/package.json index 78ee1804..738c55e8 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,19 @@ "fe-services": "nx run-many -t serve --parallel=10 --no-cloud --projects user-service job-service entity-service research-service", "all": "nx run-many --parallel=100 -t serve --no-cloud", "prepare": "husky", - "test:ci": "nx run-many -t test --parallel=1 --no-cloud --coverage --runInBand --passWithNoTests --exclude database", + "test:ci": "nx run-many -t test --parallel=1 --no-cloud --coverage --runInBand --passWithNoTests", "lint-build": "nx run-many -t lint build --no-cloud" }, "private": true, "dependencies": { "@aws-sdk/client-ecs": "^3.766.0", "@casl/ability": "^6.7.3", + "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/bullmq": "^11.0.2", "@nestjs/common": "^11.0.11", "@nestjs/config": "^4.0.1", "@nestjs/core": "^11.0.11", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^11.0.0", "@nestjs/platform-express": "^11.0.11", "@nestjs/schedule": "^5.0.1", @@ -38,6 +40,7 @@ "debug": "^4.4.0", "geojson": "^0.5.0", "hbs": "^4.2.0", + "ioredis": "^5.6.1", "lodash": "^4.17.21", "luxon": "^3.5.0", "mariadb": "^3.4.0", @@ -89,8 +92,10 @@ "husky": "^9.1.7", "jest": "^29.7.0", "jest-environment-node": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "nx": "20.6.1", "prettier": "^2.6.2", + "sqlite3": "^5.1.7", "supertest": "^7.0.0", "ts-jest": "^29.2.6", "ts-node": "10.9.2", diff --git a/tm-v3-cli/src/repl/command.ts b/tm-v3-cli/src/repl/command.ts index d044da20..471fa45d 100644 --- a/tm-v3-cli/src/repl/command.ts +++ b/tm-v3-cli/src/repl/command.ts @@ -1,4 +1,4 @@ -import { Argument, Command } from "commander"; +import { Argument, Command, Option, OptionValues } from "commander"; import { rootDebug } from "../utils"; import { ECSClient, ExecuteCommandCommand, ListTasksCommand } from "@aws-sdk/client-ecs"; import { CLUSTER, Environment, ENVIRONMENTS, Service, SERVICES } from "../consts"; @@ -13,6 +13,9 @@ const debugError = rootDebug.extend("remote-repl:error"); const TERM_OPTIONS = { rows: 34, cols: 197 }; +const REMOTE_COMMANDS = ["repl", "sh"] as const; +type RemoteCommand = (typeof REMOTE_COMMANDS)[number]; + const startLocalRepl = async (service: Service) => { debug(`Building REPL for ${service}`); await new Promise(resolve => { @@ -29,7 +32,7 @@ const startLocalRepl = async (service: Service) => { }); debug(`Launching REPL process for ${service}`); - spawn("node", [`dist/apps/${service}-repl`], { stdio: "inherit" }); + spawn("node", [`dist/apps/${service}-repl`], { stdio: "inherit", env: { ...process.env, NODE_ENV: "development" } }); }; const getTaskId = async (service: Service, env: Environment) => { @@ -46,17 +49,32 @@ const getTaskId = async (service: Service, env: Environment) => { return undefined; }; -const startRemoteRepl = async (taskId: string, service: Service) => { +const getRemoteCommandString = (service: Service, remoteCommand: RemoteCommand) => { + switch (remoteCommand) { + case "repl": + return `node dist/apps/${service}-repl`; + case "sh": + return "sh"; + + default: + debugError(`Unrecognized command: ${remoteCommand}`); + process.exit(-1); + } +}; + +const startRemoteRepl = async (taskId: string, service: Service, remoteCommand: RemoteCommand) => { const client = new ECSClient(); - const command = new ExecuteCommandCommand({ + const command = getRemoteCommandString(service, remoteCommand); + debug(`Setting command to run after connection: ${command}`); + const executionCommand = new ExecuteCommandCommand({ cluster: CLUSTER, task: taskId, interactive: true, - command: `node dist/apps/${service}-repl` + command }); const { session: { streamUrl, tokenValue } - } = await client.send(command); + } = await client.send(executionCommand); const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); @@ -109,7 +127,12 @@ export const replCommand = () => .addArgument( new Argument("environment", "The environment to connect to").choices(ENVIRONMENTS).default("local").argOptional() ) - .action(async (service: Service, environment: Environment) => { + .addOption( + new Option("-c, --command ", "Ignored in local environment. Command to run after connecting.") + .default("repl") + .choices(REMOTE_COMMANDS) + ) + .action(async (service: Service, environment: Environment, options: OptionValues) => { if (environment === "local") { await startLocalRepl(service); } else { @@ -117,6 +140,6 @@ export const replCommand = () => if (taskId == null) return; debug(`Found task id: ${taskId}`); - await startRemoteRepl(taskId, service); + await startRemoteRepl(taskId, service, options.command as RemoteCommand); } }); diff --git a/tsconfig.base.json b/tsconfig.base.json index abeb17c7..22b19687 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,6 +17,7 @@ "paths": { "@terramatch-microservices/common": ["libs/common/src/index.ts"], "@terramatch-microservices/common/*": ["libs/common/src/lib/*"], + "@terramatch-microservices/data-api": ["libs/data-api/src/index.ts"], "@terramatch-microservices/database": ["libs/database/src/index.ts"], "@terramatch-microservices/database/*": ["libs/database/src/lib/*"], "@terramatch-microservices/tm-v3-cli": ["tm-v3-cli/src/index.ts"]