diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index c2cbc741f44..79fde1d5e5b 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -54,6 +54,8 @@ import { ApiKeyGuard } from '../guards/api-key.guard'; import { PublicAppsViewQueryParams } from '../dtos/applications/public-apps-view-params.dto'; import { PublicAppsViewResponse } from '../dtos/applications/public-apps-view-response.dto'; import { ApplicationBulkUploadService } from '../services/application-bulk-upload.service'; +import { ApplicationBulkUrl } from '../dtos/applications/application-bulk-url.dto'; +import { ApplicationBulkPresignedUrl } from '../dtos/applications/application-bulk-presigned-url.dto'; @Controller('applications') @ApiTags('applications') @@ -327,6 +329,18 @@ export class ApplicationController { ); } + @Post('bulk-update/upload-url') + @ApiOperation({ + summary: 'Generates an presigned URL link to a private S3 bucket', + operationId: 'uploadBulkUpdate', + }) + @ApiOkResponse({ type: ApplicationBulkPresignedUrl }) + async uploadBulkUpdate( + @Body() dto: ApplicationBulkUrl, + ): Promise { + return this.applicationBulkUploadService.uploadUrl(dto); + } + @Delete() @ApiOperation({ summary: 'Delete application by id', operationId: 'delete' }) @ApiOkResponse({ type: SuccessDTO }) diff --git a/api/src/dtos/applications/application-bulk-presigned-url.dto.ts b/api/src/dtos/applications/application-bulk-presigned-url.dto.ts new file mode 100644 index 00000000000..2459cb156aa --- /dev/null +++ b/api/src/dtos/applications/application-bulk-presigned-url.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ApplicationBulkPresignedUrl { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + key: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + presignedUrl: string; +} diff --git a/api/src/dtos/applications/application-bulk-url.dto.ts b/api/src/dtos/applications/application-bulk-url.dto.ts new file mode 100644 index 00000000000..6e7afe2f953 --- /dev/null +++ b/api/src/dtos/applications/application-bulk-url.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, MinLength, IsString, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ApplicationBulkUrl { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiProperty() + listingId: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiProperty() + userId: string; +} diff --git a/api/src/modules/application-bulk-upload.module.ts b/api/src/modules/application-bulk-upload.module.ts index e5916d69482..9b40287d616 100644 --- a/api/src/modules/application-bulk-upload.module.ts +++ b/api/src/modules/application-bulk-upload.module.ts @@ -3,10 +3,11 @@ import { ApplicationBulkUploadService } from '../services/application-bulk-uploa import { PrismaModule } from './prisma.module'; import { ListingModule } from './listing.module'; import { PermissionModule } from './permission.module'; +import { S3Module } from './s3.module'; @Global() @Module({ - imports: [PrismaModule, ListingModule, PermissionModule], + imports: [PrismaModule, ListingModule, PermissionModule, S3Module], providers: [ApplicationBulkUploadService], exports: [ApplicationBulkUploadService], }) diff --git a/api/src/services/application-bulk-upload.service.ts b/api/src/services/application-bulk-upload.service.ts index 121e8d052e8..e6c737f9c1a 100644 --- a/api/src/services/application-bulk-upload.service.ts +++ b/api/src/services/application-bulk-upload.service.ts @@ -19,6 +19,12 @@ import { ListingService } from './listing.service'; import { PermissionService } from './permission.service'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; import { convertApplicationDeclineReasonToReadable } from '../utilities/application-export-helpers'; +import { ApplicationBulkUrl } from '../dtos/applications/application-bulk-url.dto'; +import { doJurisdictionHaveFeatureFlagSet } from '../utilities/feature-flag-utilities'; +import { FeatureFlagEnum } from '../enums/feature-flags/feature-flags-enum'; +import { Jurisdiction } from '../dtos/jurisdictions/jurisdiction.dto'; +import { ApplicationBulkPresignedUrl } from '../dtos/applications/application-bulk-presigned-url.dto'; +import { S3Service } from './s3.service'; const NUMBER_TO_PAGINATE_BY = 500; @@ -30,6 +36,7 @@ export class ApplicationBulkUploadService { private prisma: PrismaService, private listingService: ListingService, private permissionService: PermissionService, + private s3Service: S3Service, ) {} private formatApplicationStatus(statusEnum: ApplicationStatusEnum): string { @@ -295,6 +302,71 @@ export class ApplicationBulkUploadService { return stringData; } + // TODO Yazeed - Integrate the uploadUrl method with S3 presigned URL generation logic + async uploadUrl( + dto: ApplicationBulkUrl, + ): Promise { + const { userId, listingId } = dto; + + const listingData = await this.prisma.listings.findUnique({ + select: { + // id: true, + jurisdictionId: true, + jurisdictions: { + select: { + featureFlags: true, + }, + }, + }, + where: { + id: listingId, + }, + }); + + if (!listingData) { + throw new NotFoundException( + `Listing with id: ${listingId} can not be found`, + ); + } + + const requestingUser = await this.prisma.userAccounts.findFirst({ + where: { + id: userId, + }, + }); + + await this.permissionService.canOrThrow( + mapTo(User, requestingUser), + 'listing', + permissionActions.update, + { + id: listingId, + jurisdictionId: listingData.jurisdictionId, + }, + ); + + if ( + !doJurisdictionHaveFeatureFlagSet( + mapTo(Jurisdiction, listingData.jurisdictions), + FeatureFlagEnum.enableApplicationStatus, + ) + ) { + throw new ForbiddenException( + `Jurisdiction with id: ${listingData.jurisdictionId} does not have the enableApplicationStatus flag enabled`, + ); + } + + const s3KeyTemplate = `bulk-application-updates-${listingId}-${userId}-${new Date().toISOString()}`; + const presignedUrl = await this.s3Service.uploadURLForPrivate( + s3KeyTemplate, + ); + + return { + key: s3KeyTemplate, + presignedUrl: presignedUrl, + }; + } + async authorizeExport(user, listingId): Promise { /** * Checking authorization for each application is very expensive. diff --git a/api/src/services/s3.service.ts b/api/src/services/s3.service.ts index 6fdd522a6b6..9c680d61477 100644 --- a/api/src/services/s3.service.ts +++ b/api/src/services/s3.service.ts @@ -105,6 +105,30 @@ export class S3Service { return getSignedUrl(this.s3Client, command); } + async uploadURLForPrivate( + key: string, + metadata?: CreateS3UploadMetadata, + expiresIn: number = 60 * 5, + ): Promise { + const input: PutObjectCommandInput = { + Bucket: this.privateBucket, + Key: key, + }; + + if (metadata?.contentDisposition) { + input.ContentDisposition = metadata.contentDisposition; + } + if (metadata?.contentType) { + input.ContentType = metadata.contentType; + } + + const command = new PutObjectCommand(input); + + return getSignedUrl(this.s3Client, command, { + expiresIn, + }); + } + urlForPublic(key: string): string { return `https://${this.publicBucket}.s3.${this.region}.amazonaws.com/${key}`; } diff --git a/api/test/unit/services/application-bulk-upload.service.spec.ts b/api/test/unit/services/application-bulk-upload.service.spec.ts index 5ae351a6af7..aae2a7bedd7 100644 --- a/api/test/unit/services/application-bulk-upload.service.spec.ts +++ b/api/test/unit/services/application-bulk-upload.service.spec.ts @@ -18,6 +18,7 @@ import { ApplicationBulkUploadService } from '../../../src/services/application- import { ListingService } from '../../../src/services/listing.service'; import { PermissionService } from '../../../src/services/permission.service'; import { PrismaService } from '../../../src/services/prisma.service'; +import { S3Service } from '../../../src/services/s3.service'; const mockApplication = ({ markedAsDuplicate = false, @@ -91,6 +92,16 @@ describe('Testing application bulk upload services', () => { provide: PermissionService, useValue: { canOrThrow: canOrThrowMock }, }, + { + provide: S3Service, + useValue: { + uploadToPrivate: jest.fn(), + urlForPrivate: jest.fn(), + uploadURLForPublic: jest.fn(), + uploadURLForPrivate: jest.fn(), + urlForPublic: jest.fn(), + }, + }, ], }).compile();