From 81f7a13dfc1133fffb75bfb7c14c7660ae9d9fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pleba=C5=84ski?= Date: Wed, 9 Jun 2021 16:11:08 +0200 Subject: [PATCH] Add assets module (#1307) * Add basic tests for assets module * Add eager to createpresignedurl dto and implement CloudinaryService * Move AppModule imports from module to dynamic resolver * Rework assets service to return timestamp and signature * Rework assets service to return timestamp and signature * Add missing ApiProperty decorator for FormMetadata properties * Add fake cloudinary environment variables to circleci config * Add fake cloudinary secrets to circleci workflows * Fix unit tests * Update changelog * Add assets migration --- .circleci/config.yml | 4 + CHANGELOG.md | 1 + backend/core/.env.template | 2 + backend/core/archer.ts | 3 + backend/core/package.json | 3 +- backend/core/src/app.module.ts | 7 +- .../form-metadata/form-metadata-options.ts | 13 +- .../types/form-metadata/form-metadata.ts | 16 ++- .../core/src/assets/assets.controller.spec.ts | 24 ++++ backend/core/src/assets/assets.controller.ts | 49 ++++++++ backend/core/src/assets/assets.module.ts | 15 +++ backend/core/src/assets/dto/asset.dto.ts | 20 ++++ .../core/src/assets/entities/asset.entity.ts | 20 ++++ .../assets/services/assets.service.spec.ts | 25 ++++ .../src/assets/services/assets.service.ts | 30 +++++ .../src/assets/services/upload.service.ts | 25 ++++ backend/core/src/auth/auth.module.ts | 6 - .../1623247120126-add-assets-table.ts | 18 +++ backend/core/src/seeder/seeder.module.ts | 4 +- backend/core/src/shared/email/email.module.ts | 1 - backend/core/src/shared/shared.module.ts | 13 +- backend/core/test/assets/assets.e2e-spec.ts | 60 ++++++++++ backend/core/types/src/archer-listing.ts | 3 + backend/core/types/src/backend-swagger.ts | 111 +++++++++++++++--- 24 files changed, 434 insertions(+), 39 deletions(-) create mode 100644 backend/core/src/assets/assets.controller.spec.ts create mode 100644 backend/core/src/assets/assets.controller.ts create mode 100644 backend/core/src/assets/assets.module.ts create mode 100644 backend/core/src/assets/dto/asset.dto.ts create mode 100644 backend/core/src/assets/entities/asset.entity.ts create mode 100644 backend/core/src/assets/services/assets.service.spec.ts create mode 100644 backend/core/src/assets/services/assets.service.ts create mode 100644 backend/core/src/assets/services/upload.service.ts create mode 100644 backend/core/src/migration/1623247120126-add-assets-table.ts create mode 100644 backend/core/test/assets/assets.e2e-spec.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 98454855e7..8ddadfebb3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,6 +27,8 @@ executors: REDIS_TLS_URL: "rediss://localhost:6379/0" REDIS_URL: "redis://localhost:6379/0" REDIS_USE_TLS: "0" + CLOUDINARY_SECRET: "fake_secret" + CLOUDINARY_KEY: "fake_key" jobs: setup: @@ -76,6 +78,8 @@ jobs: REDIS_TLS_URL: "rediss://localhost:6379/0" REDIS_URL: "redis://localhost:6379/0" REDIS_USE_TLS: "0" + CLOUDINARY_SECRET: "fake_secret" + CLOUDINARY_KEY: "fake_key" build-public: executor: standard-node steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd8b6207d..989ce69262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file. The format - Added "closed" to ListingStatus enum - Added Transform to ListingStatus field to return closed if applicationDueDate is in the past - Added "ohaFormat" to CSV exporter (includes OHA and HOPWA preferences) ([#1292](https://github.com/bloom-housing/bloom/pull/1292)) (Michał Plebański) + - `/assets` endpoints (create and createPresignedUploadMetadata) ### Frontend diff --git a/backend/core/.env.template b/backend/core/.env.template index 57887e3b0d..88888f698a 100644 --- a/backend/core/.env.template +++ b/backend/core/.env.template @@ -9,3 +9,5 @@ THROTTLE_LIMIT=2 EMAIL_API_KEY='SOME-LONG-SECRET-KEY' EMAIL_FROM_ADDRESS='Bloom Dev Housing Portal ' APP_SECRET='SOME-LONG-SECRET-KEY' +CLOUDINARY_SECRET=CLOUDINARY_SECRET +CLOUDINARY_KEY=CLOUDINARY_KEY diff --git a/backend/core/archer.ts b/backend/core/archer.ts index 8c599c6253..2aec922b0d 100644 --- a/backend/core/archer.ts +++ b/backend/core/archer.ts @@ -301,6 +301,9 @@ export const ArcherListing: Listing = { // referenceType: "Listing", // TODO confirm not used anywhere // referenceId: "Uvbk5qurpB2WI9V6WnNdH", + id: "id", + createdAt: new Date(), + updatedAt: new Date(), label: "building", fileId: "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/archer/archer-studios.jpg", diff --git a/backend/core/package.json b/backend/core/package.json index b9af6a8212..463d41f034 100644 --- a/backend/core/package.json +++ b/backend/core/package.json @@ -23,7 +23,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./jest-e2e.json --runInBand --forceExit", - "test:e2e:local": "psql -c 'DROP DATABASE IF EXISTS bloom_test' && psql -c 'CREATE DATABASE bloom_test' && ts-node ./node_modules/.bin/typeorm --config ./ormconfig.test.ts migration:run && ts-node src/seed.ts --test && jest --config ./jest-e2e.json --runInBand --forceExit", + "test:e2e:local": "psql -c 'DROP DATABASE IF EXISTS bloom_test' && psql -c 'CREATE DATABASE bloom_test' && ts-node ./node_modules/.bin/typeorm --config ./ormconfig.test.ts migration:run && ts-node src/seed.ts --test && yarn run test:e2e", "typeorm": "ts-node ./node_modules/.bin/typeorm", "herokusetup": "node heroku.setup.js", "heroku-postbuild": "rimraf dist && nest build && yarn run db:migration:run", @@ -48,6 +48,7 @@ "casbin": "^5.1.6", "class-transformer": "0.3.1", "class-validator": "^0.12.2", + "cloudinary": "^1.25.2", "dotenv": "^8.2.0", "express": "^4.17.1", "handlebars": "^4.7.6", diff --git a/backend/core/src/app.module.ts b/backend/core/src/app.module.ts index c40a9356d0..a503902276 100644 --- a/backend/core/src/app.module.ts +++ b/backend/core/src/app.module.ts @@ -23,6 +23,7 @@ import { SharedModule } from "./shared/shared.module" import { ConfigModule, ConfigService } from "@nestjs/config" import { TranslationsModule } from "./translations/translations.module" import { Reflector } from "@nestjs/core" +import { AssetsModule } from "./assets/assets.module" export function applicationSetup(app: INestApplication) { app.enableCors() @@ -36,9 +37,7 @@ export function applicationSetup(app: INestApplication) { return app } -@Module({ - imports: [ApplicationFlaggedSetsModule], -}) +@Module({}) export class AppModule { static register(dbOptions): DynamicModule { /** @@ -88,6 +87,8 @@ export class AppModule { AmiChartsModule, SharedModule, TranslationsModule, + ApplicationFlaggedSetsModule, + AssetsModule, ], } } diff --git a/backend/core/src/applications/types/form-metadata/form-metadata-options.ts b/backend/core/src/applications/types/form-metadata/form-metadata-options.ts index 0ca7db8a3a..97e6d68304 100644 --- a/backend/core/src/applications/types/form-metadata/form-metadata-options.ts +++ b/backend/core/src/applications/types/form-metadata/form-metadata-options.ts @@ -1,5 +1,12 @@ import { Expose, Type } from "class-transformer" -import { ArrayMaxSize, IsOptional, IsString, MaxLength, ValidateNested } from "class-validator" +import { + ArrayMaxSize, + IsBoolean, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator" import { ValidationsGroupsEnum } from "../../../shared/types/validations-groups-enum" import { FormMetadataExtraData } from "./form-metadata-extra-data" import { ApiProperty } from "@nestjs/swagger" @@ -21,9 +28,13 @@ export class FormMetadataOptions { @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() description?: boolean @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() exclusive?: boolean } diff --git a/backend/core/src/applications/types/form-metadata/form-metadata.ts b/backend/core/src/applications/types/form-metadata/form-metadata.ts index 0bc61afce4..54a52a81e8 100644 --- a/backend/core/src/applications/types/form-metadata/form-metadata.ts +++ b/backend/core/src/applications/types/form-metadata/form-metadata.ts @@ -1,5 +1,12 @@ import { Expose, Type } from "class-transformer" -import { ArrayMaxSize, IsString, MaxLength, ValidateNested, IsOptional } from "class-validator" +import { + ArrayMaxSize, + IsString, + MaxLength, + ValidateNested, + IsOptional, + IsBoolean, +} from "class-validator" import { ValidationsGroupsEnum } from "../../../shared/types/validations-groups-enum" import { FormMetadataOptions } from "./form-metadata-options" import { ApiProperty } from "@nestjs/swagger" @@ -20,14 +27,19 @@ export class FormMetadata { @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() hideGenericDecline?: boolean @Expose() - @IsString({ groups: [ValidationsGroupsEnum.default] }) @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() customSelectText?: string @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() hideFromListing?: boolean } diff --git a/backend/core/src/assets/assets.controller.spec.ts b/backend/core/src/assets/assets.controller.spec.ts new file mode 100644 index 0000000000..419d7e1daa --- /dev/null +++ b/backend/core/src/assets/assets.controller.spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { AssetsController } from "./assets.controller" +import { AuthModule } from "../auth/auth.module" +import dbOptions = require("../../ormconfig.test") +import { TypeOrmModule } from "@nestjs/typeorm" +import { AssetsService } from "./services/assets.service" + +describe("AssetsController", () => { + let controller: AssetsController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AssetsController], + imports: [TypeOrmModule.forRoot(dbOptions), AuthModule], + providers: [{ provide: AssetsService, useValue: {} }], + }).compile() + + controller = module.get(AssetsController) + }) + + it("should be defined", () => { + expect(controller).toBeDefined() + }) +}) diff --git a/backend/core/src/assets/assets.controller.ts b/backend/core/src/assets/assets.controller.ts new file mode 100644 index 0000000000..2f0f644722 --- /dev/null +++ b/backend/core/src/assets/assets.controller.ts @@ -0,0 +1,49 @@ +import { Body, Controller, Post, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { mapTo } from "../shared/mapTo" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AssetsService } from "./services/assets.service" +import { + AssetCreateDto, + AssetDto, + CreatePresignedUploadMetadataDto, + CreatePresignedUploadMetadataResponseDto, +} from "./dto/asset.dto" + +@Controller("assets") +@ApiTags("assets") +@ApiBearerAuth() +@ResourceType("asset") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes( + new ValidationPipe({ + ...defaultValidationPipeOptions, + }) +) +export class AssetsController { + constructor(private readonly assetsService: AssetsService) {} + + @Post() + @ApiOperation({ summary: "Create asset", operationId: "create" }) + async create(@Body() assetCreateDto: AssetCreateDto): Promise { + const asset = await this.assetsService.create(assetCreateDto) + return mapTo(AssetDto, asset) + } + + @Post("/presigned-upload-metadata") + @ApiOperation({ + summary: "Create presigned upload metadata", + operationId: "createPresignedUploadMetadata", + }) + async createPresignedUploadMetadata( + @Body() createPresignedUploadMetadataDto: CreatePresignedUploadMetadataDto + ): Promise { + return mapTo( + CreatePresignedUploadMetadataResponseDto, + await this.assetsService.createPresignedUploadMetadata(createPresignedUploadMetadataDto) + ) + } +} diff --git a/backend/core/src/assets/assets.module.ts b/backend/core/src/assets/assets.module.ts new file mode 100644 index 0000000000..56f0a53c2a --- /dev/null +++ b/backend/core/src/assets/assets.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common" +import { AssetsController } from "./assets.controller" +import { AssetsService } from "./services/assets.service" +import { CloudinaryService, UploadService } from "./services/upload.service" +import { TypeOrmModule } from "@nestjs/typeorm" +import { SharedModule } from "../shared/shared.module" +import { Asset } from "./entities/asset.entity" +import { AuthModule } from "../auth/auth.module" + +@Module({ + controllers: [AssetsController], + providers: [AssetsService, { provide: UploadService, useClass: CloudinaryService }], + imports: [TypeOrmModule.forFeature([Asset]), AuthModule, SharedModule], +}) +export class AssetsModule {} diff --git a/backend/core/src/assets/dto/asset.dto.ts b/backend/core/src/assets/dto/asset.dto.ts new file mode 100644 index 0000000000..30895d6801 --- /dev/null +++ b/backend/core/src/assets/dto/asset.dto.ts @@ -0,0 +1,20 @@ +import { OmitType } from "@nestjs/swagger" +import { Asset } from "../entities/asset.entity" +import { Expose } from "class-transformer" +import { IsDefined } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class AssetDto extends OmitType(Asset, [] as const) {} + +export class AssetCreateDto extends OmitType(AssetDto, ["id", "createdAt", "updatedAt"] as const) {} + +export class CreatePresignedUploadMetadataDto { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + parametersToSign: Record +} + +export class CreatePresignedUploadMetadataResponseDto { + @Expose() + signature: string +} diff --git a/backend/core/src/assets/entities/asset.entity.ts b/backend/core/src/assets/entities/asset.entity.ts new file mode 100644 index 0000000000..937898eb6d --- /dev/null +++ b/backend/core/src/assets/entities/asset.entity.ts @@ -0,0 +1,20 @@ +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Expose } from "class-transformer" +import { IsString, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Column, Entity } from "typeorm" + +@Entity({ name: "assets" }) +export class Asset extends AbstractEntity { + @Column({ type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + fileId: string + + @Column({ type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + label: string +} diff --git a/backend/core/src/assets/services/assets.service.spec.ts b/backend/core/src/assets/services/assets.service.spec.ts new file mode 100644 index 0000000000..bdf62274bb --- /dev/null +++ b/backend/core/src/assets/services/assets.service.spec.ts @@ -0,0 +1,25 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { AssetsService } from "./assets.service" +import { getRepositoryToken } from "@nestjs/typeorm" +import { Asset } from "../entities/asset.entity" +import { UploadService } from "./upload.service" + +describe("AssetsService", () => { + let service: AssetsService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssetsService, + { provide: getRepositoryToken(Asset), useValue: {} }, + { provide: UploadService, useValue: {} }, + ], + }).compile() + + service = module.get(AssetsService) + }) + + it("should be defined", () => { + expect(service).toBeDefined() + }) +}) diff --git a/backend/core/src/assets/services/assets.service.ts b/backend/core/src/assets/services/assets.service.ts new file mode 100644 index 0000000000..179c0b541a --- /dev/null +++ b/backend/core/src/assets/services/assets.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common" +import { + AssetCreateDto, + CreatePresignedUploadMetadataDto, + CreatePresignedUploadMetadataResponseDto, +} from "../dto/asset.dto" +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { Asset } from "../entities/asset.entity" +import { UploadService } from "./upload.service" + +@Injectable() +export class AssetsService { + constructor( + @InjectRepository(Asset) private readonly repository: Repository, + private readonly uploadService: UploadService + ) {} + + async create(assetCreateDto: AssetCreateDto) { + return await this.repository.save(assetCreateDto) + } + + createPresignedUploadMetadata( + createUploadUrlDto: CreatePresignedUploadMetadataDto + ): Promise { + return Promise.resolve( + this.uploadService.createPresignedUploadMetadata(createUploadUrlDto.parametersToSign) + ) + } +} diff --git a/backend/core/src/assets/services/upload.service.ts b/backend/core/src/assets/services/upload.service.ts new file mode 100644 index 0000000000..b4108985a4 --- /dev/null +++ b/backend/core/src/assets/services/upload.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from "@nestjs/common" +import { ConfigService } from "@nestjs/config" +import { v2 as cloudinary } from "cloudinary" + +export abstract class UploadService { + abstract createPresignedUploadMetadata( + parametersToSign: Record + ): { signature: string } +} + +@Injectable() +export class CloudinaryService implements UploadService { + constructor(private readonly configService: ConfigService) {} + + createPresignedUploadMetadata(parametersToSign: Record): { signature: string } { + // Based on https://cloudinary.com/documentation/upload_images#signed_upload_video_tutorial + const signature = cloudinary.utils.api_sign_request( + parametersToSign, + this.configService.get("CLOUDINARY_SECRET") + ) + return { + signature, + } + } +} diff --git a/backend/core/src/auth/auth.module.ts b/backend/core/src/auth/auth.module.ts index c7c61d5870..54903dee33 100644 --- a/backend/core/src/auth/auth.module.ts +++ b/backend/core/src/auth/auth.module.ts @@ -11,7 +11,6 @@ import { SharedModule } from "../shared/shared.module" import { AuthzService } from "./authz.service" import { ConfigModule, ConfigService } from "@nestjs/config" import { UserModule } from "../user/user.module" -import Joi from "joi" @Module({ imports: [ @@ -29,11 +28,6 @@ import Joi from "joi" TypeOrmModule.forFeature([RevokedToken]), SharedModule, forwardRef(() => UserModule), - ConfigModule.forRoot({ - validationSchema: Joi.object({ - APP_SECRET: Joi.string().required().min(16), - }), - }), ], providers: [LocalStrategy, JwtStrategy, AuthService, AuthzService], exports: [AuthzService, AuthService], diff --git a/backend/core/src/migration/1623247120126-add-assets-table.ts b/backend/core/src/migration/1623247120126-add-assets-table.ts new file mode 100644 index 0000000000..053b9065c3 --- /dev/null +++ b/backend/core/src/migration/1623247120126-add-assets-table.ts @@ -0,0 +1,18 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class addAssetsTable1623247120126 implements MigrationInterface { + name = 'addAssetsTable1623247120126' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_8cb54e950245d30651b903a4c61"`); + await queryRunner.query(`DROP INDEX "IDX_ada354174d7f8a8f3d56c39bba"`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "listing_id"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ADD "listing_id" uuid`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ada354174d7f8a8f3d56c39bba" ON "translations" ("county_code", "language") `); + await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_8cb54e950245d30651b903a4c61" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} diff --git a/backend/core/src/seeder/seeder.module.ts b/backend/core/src/seeder/seeder.module.ts index 918800431c..0e7557c06d 100644 --- a/backend/core/src/seeder/seeder.module.ts +++ b/backend/core/src/seeder/seeder.module.ts @@ -10,7 +10,6 @@ import { User } from "../user/entities/user.entity" import { ListingsService } from "../listings/listings.service" import dbOptions = require("../../ormconfig") import testDbOptions = require("../../ormconfig.test") -import { ConfigModule } from "@nestjs/config" import { CsvBuilder } from "../csv/csv-builder.service" import { CsvEncoder } from "../csv/csv-encoder.service" import { PropertyGroup } from "../property-groups/entities/property-group.entity" @@ -22,6 +21,7 @@ import { AuthzService } from "../auth/authz.service" import { ApplicationFlaggedSet } from "../application-flagged-sets/entities/application-flagged-set.entity" import { ApplicationsModule } from "../applications/applications.module" import { ThrottlerModule } from "@nestjs/throttler" +import { SharedModule } from "../shared/shared.module" @Module({}) export class SeederModule { @@ -32,7 +32,7 @@ export class SeederModule { imports: [ ApplicationsModule, UserModule, - ConfigModule.forRoot({ isGlobal: true }), + SharedModule, TypeOrmModule.forRoot({ ...dbConfig, }), diff --git a/backend/core/src/shared/email/email.module.ts b/backend/core/src/shared/email/email.module.ts index 844016e2a0..b5a6bf3044 100644 --- a/backend/core/src/shared/email/email.module.ts +++ b/backend/core/src/shared/email/email.module.ts @@ -8,7 +8,6 @@ import { CountyCodeResolverService } from "../services/county-code-resolver.serv @Module({ imports: [ - ConfigModule, SharedModule, TranslationsModule, SendGridModule.forRootAsync({ diff --git a/backend/core/src/shared/shared.module.ts b/backend/core/src/shared/shared.module.ts index 7d4fbaf04b..b5966790a8 100644 --- a/backend/core/src/shared/shared.module.ts +++ b/backend/core/src/shared/shared.module.ts @@ -1,24 +1,29 @@ import { Module } from "@nestjs/common" -import { ConfigModule } from "@nestjs/config" +import { ConfigModule, ConfigService } from "@nestjs/config" import Joi from "joi" @Module({ imports: [ ConfigModule.forRoot({ - isGlobal: true, validationSchema: Joi.object({ PORT: Joi.number().default(3100).required(), NODE_ENV: Joi.string() .valid("development", "staging", "production", "test") .default("development"), + EMAIL_API_KEY: Joi.string().required(), + EMAIL_FROM_ADDRESS: Joi.string().required(), DATABASE_URL: Joi.string().required(), REDIS_TLS_URL: Joi.string().required(), + REDIS_USE_TLS: Joi.number().required(), THROTTLE_TTL: Joi.number().default(1), THROTTLE_LIMIT: Joi.number().default(9999999999999999), + APP_SECRET: Joi.string().required().min(16), + CLOUDINARY_SECRET: Joi.string().required(), + CLOUDINARY_KEY: Joi.string().required(), }), }), ], - providers: [], - exports: [], + providers: [ConfigService], + exports: [ConfigService], }) export class SharedModule {} diff --git a/backend/core/test/assets/assets.e2e-spec.ts b/backend/core/test/assets/assets.e2e-spec.ts new file mode 100644 index 0000000000..3e1735ead6 --- /dev/null +++ b/backend/core/test/assets/assets.e2e-spec.ts @@ -0,0 +1,60 @@ +import { Test } from "@nestjs/testing" +import { AssetsController } from "../../src/assets/assets.controller" +import { AssetsService } from "../../src/assets/services/assets.service" +import { TypeOrmModule } from "@nestjs/typeorm" +import dbOptions = require("../../ormconfig.test") +import { Asset } from "../../src/assets/entities/asset.entity" +import { UploadService } from "../../src/assets/services/upload.service" +import { SharedModule } from "../../src/shared/shared.module" +import { AuthzService } from "../../src/auth/authz.service" + +class FakeUploadService implements UploadService { + createPresignedUploadMetadata(): { signature: string } { + return { signature: "fake" } + } +} + +describe("AssetsController", () => { + let assetsController: AssetsController + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + SharedModule, + TypeOrmModule.forRoot({ ...dbOptions, keepConnectionAlive: true }), + TypeOrmModule.forFeature([Asset]), + ], + controllers: [AssetsController], + providers: [ + AssetsService, + { provide: UploadService, useClass: FakeUploadService }, + AuthzService, + ], + }).compile() + assetsController = moduleRef.get(AssetsController) + }) + + describe("create", () => { + it("should create an asset", async () => { + const assetInput = { + fileId: "fileId", + label: "label", + } + const asset = await assetsController.create(assetInput) + expect(asset).toMatchObject(assetInput) + expect(asset).toHaveProperty("id") + expect(asset).toHaveProperty("createdAt") + expect(asset).toHaveProperty("updatedAt") + }) + + it("should create a presigned url for upload", async () => { + const publicId = "publicId" + const eager = "eager" + const createPresignedUploadMetadataResponseDto = await assetsController.createPresignedUploadMetadata( + { parametersToSign: { publicId, eager } } + ) + expect(createPresignedUploadMetadataResponseDto).toHaveProperty("signature") + expect(createPresignedUploadMetadataResponseDto.signature).toBe("fake") + }) + }) +}) diff --git a/backend/core/types/src/archer-listing.ts b/backend/core/types/src/archer-listing.ts index 8fcec8b1e8..d296de9033 100644 --- a/backend/core/types/src/archer-listing.ts +++ b/backend/core/types/src/archer-listing.ts @@ -630,6 +630,9 @@ export const ArcherListing: Listing = { // applicationPhone: "(408) 217-8562", assets: [ { + id: "das", + createdAt: new Date(), + updatedAt: new Date(), label: "building", fileId: "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/archer/archer-studios.jpg", diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index 26b1c8fa0a..7cd519f039 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -1278,6 +1278,51 @@ export class ApplicationFlaggedSetsService { } } +export class AssetsService { + /** + * Create asset + */ + create( + params: { + /** requestBody */ + body?: AssetCreate; + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + '/assets'; + + const configs: IRequestConfig = getConfigs('post', 'application/json', url, options); + + let data = params.body; + + configs.data = data; + axios(configs, resolve, reject); + }); + } + /** + * Create presigned upload metadata + */ + createPresignedUploadMetadata( + params: { + /** requestBody */ + body?: CreatePresignedUploadMetadata; + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + '/assets/presigned-upload-metadata'; + + const configs: IRequestConfig = getConfigs('post', 'application/json', url, options); + + let data = params.body; + + configs.data = data; + axios(configs, resolve, reject); + }); + } +} + export interface Id { /** */ id: string; @@ -1467,13 +1512,13 @@ export interface FormMetadataOptions { key: string; /** */ - description?: boolean; + extraData?: FormMetadataExtraData[]; /** */ - exclusive?: boolean; + description: boolean; /** */ - extraData?: FormMetadataExtraData[]; + exclusive: boolean; } export interface FormMetadata { @@ -1484,13 +1529,13 @@ export interface FormMetadata { options: FormMetadataOptions[]; /** */ - hideGenericDecline?: boolean; + hideGenericDecline: boolean; - /** */ - customSelectText?: string + /** */ + customSelectText: string; - /** */ - hideFromListing?: boolean + /** */ + hideFromListing: boolean; } export interface Preference { @@ -1521,8 +1566,8 @@ export interface Preference { /** */ formMetadata?: FormMetadata; - /** */ - page: number + /** */ + page: number; } export interface MinMaxCurrency { @@ -1608,10 +1653,10 @@ export interface UnitsSummarized { amiPercentages: string[]; /** */ - byUnitType: UnitSummary[]; + byUnitTypeAndRent: UnitSummary[]; /** */ - byUnitTypeAndRent: UnitSummary[]; + byUnitType: UnitSummary[]; /** */ byNonReservedUnitType: UnitSummary[]; @@ -1867,10 +1912,19 @@ export interface ApplicationMethod { export interface Asset { /** */ - label: string; + id: string; + + /** */ + createdAt: Date; + + /** */ + updatedAt: Date; /** */ fileId: string; + + /** */ + label: string; } export interface ListingEvent { @@ -2063,8 +2117,8 @@ export interface PreferenceCreate { /** */ formMetadata?: FormMetadata; - /** */ - page: number + /** */ + page: number; } export interface AddressCreate { @@ -2241,10 +2295,10 @@ export interface PreferenceUpdate { formMetadata?: FormMetadata; /** */ - id: string; + page: number; - /** */ - page: number + /** */ + id: string; } export interface AddressUpdate { @@ -3716,6 +3770,24 @@ export interface ApplicationFlaggedSetResolve { applications: Id[]; } +export interface AssetCreate { + /** */ + fileId: string; + + /** */ + label: string; +} + +export interface CreatePresignedUploadMetadata { + /** */ + parametersToSign: object; +} + +export interface CreatePresignedUploadMetadataResponse { + /** */ + signature: string; +} + export enum UserRole { 'user' = 'user', 'admin' = 'admin' @@ -3736,7 +3808,8 @@ export enum ListingStatus { export enum CSVFormattingType { 'basic' = 'basic', - 'withDisplaceeNameAndAddress' = 'withDisplaceeNameAndAddress' + 'withDisplaceeNameAndAddress' = 'withDisplaceeNameAndAddress', + 'ohaFormat' = 'ohaFormat' } export enum CountyCode {