From d282965255bdf21fb776c0dae0457c5de64c3f7f Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 6 Jul 2026 17:02:28 +0200 Subject: [PATCH 01/15] chore: setup a DTO for the bulk upload s3 endpoint --- .../application-bulk-upload.dto.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 api/src/dtos/applications/application-bulk-upload.dto.ts diff --git a/api/src/dtos/applications/application-bulk-upload.dto.ts b/api/src/dtos/applications/application-bulk-upload.dto.ts new file mode 100644 index 00000000000..f04ad26544c --- /dev/null +++ b/api/src/dtos/applications/application-bulk-upload.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, MinLength, MaxLength, IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ApplicationBulkUpload { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiProperty() + uploadUrl: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiProperty() + s3Key: string; +} From a2c6e3721c170465105196e5aed60383e0c7f14d Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 8 Jul 2026 22:22:36 +0200 Subject: [PATCH 02/15] chore: setup new application controller endpoint --- api/src/controllers/application.controller.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index c2cbc741f44..7a3d22bbb0f 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -54,6 +54,7 @@ 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 { ApplicationBulkUpload } from '../dtos/applications/application-bulk-upload.dto'; @Controller('applications') @ApiTags('applications') @@ -327,6 +328,17 @@ export class ApplicationController { ); } + @Post('bulk-update/upload-url') + @ApiOperation({ + summary: + 'Allows the user to upload the bulk upload CSV to a private S3 bucket', + operationId: 'uploadBulkUpdate', + }) + @ApiOkResponse({ type: Boolean }) + async uploadBulkUpdate(@Body() dto: ApplicationBulkUpload): Promise { + return this.applicationBulkUploadService.uploadUrl(dto); + } + @Delete() @ApiOperation({ summary: 'Delete application by id', operationId: 'delete' }) @ApiOkResponse({ type: SuccessDTO }) From ab92cf5ea5b1ffca7e1565fb040092a541348704 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 8 Jul 2026 22:24:43 +0200 Subject: [PATCH 03/15] chore: setup a base bulk application service method --- .../application-bulk-upload.service.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/api/src/services/application-bulk-upload.service.ts b/api/src/services/application-bulk-upload.service.ts index 121e8d052e8..694dfd90b53 100644 --- a/api/src/services/application-bulk-upload.service.ts +++ b/api/src/services/application-bulk-upload.service.ts @@ -3,6 +3,7 @@ import { Injectable, StreamableFile, NotFoundException, + BadRequestException, } from '@nestjs/common'; import { ApplicationStatusEnum } from '@prisma/client'; import fs, { createReadStream } from 'fs'; @@ -19,9 +20,19 @@ 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 { ApplicationBulkUpload } from '../dtos/applications/application-bulk-upload.dto'; +import { doJurisdictionHaveFeatureFlagSet } from 'src/utilities/feature-flag-utilities'; +import { FeatureFlagEnum } from 'src/enums/feature-flags/feature-flags-enum'; +import { Jurisdiction } from 'src/dtos/jurisdictions/jurisdiction.dto'; const NUMBER_TO_PAGINATE_BY = 500; +const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; +const BULK_UPLOAD_KEY_PATTERN = new RegExp( + `^bulk-application-updates-(${UUID})-(${UUID})-(.+)\\.csv$`, + 'i', +); + @Injectable() export class ApplicationBulkUploadService { private dateFormat = 'MM-DD-YYYY hh:mm:ssA z'; @@ -295,6 +306,68 @@ export class ApplicationBulkUploadService { return stringData; } + async uploadUrl(dto: ApplicationBulkUpload) { + const { s3Key, uploadUrl } = dto; + + const match = BULK_UPLOAD_KEY_PATTERN.exec(s3Key); + if (!match) { + throw new BadRequestException( + 's3Key must match format: bulk-application-updates-{listingId}-{userId}-{time-generated}.csv', + ); + } + + const [, listingId, userId] = match; + + const listingData = await this.prisma.listings.findUnique({ + select: { + 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 BadRequestException( + `Jurisdiction with id: ${listingData.jurisdictionId} does not have the enableApplicationStatus flag enabled`, + ); + } + + return true; + } + async authorizeExport(user, listingId): Promise { /** * Checking authorization for each application is very expensive. From d0236a1619b51dbce6f74e8f18a034845e043b38 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Thu, 9 Jul 2026 21:42:08 +0200 Subject: [PATCH 04/15] chore: update listing feature flag exception type --- api/src/services/application-bulk-upload.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/services/application-bulk-upload.service.ts b/api/src/services/application-bulk-upload.service.ts index 694dfd90b53..39c4642b31f 100644 --- a/api/src/services/application-bulk-upload.service.ts +++ b/api/src/services/application-bulk-upload.service.ts @@ -360,7 +360,7 @@ export class ApplicationBulkUploadService { FeatureFlagEnum.enableApplicationStatus, ) ) { - throw new BadRequestException( + throw new ForbiddenException( `Jurisdiction with id: ${listingData.jurisdictionId} does not have the enableApplicationStatus flag enabled`, ); } From 12574e3c67031ac857e9a7e77c3cb9cb0f4db9ea Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Thu, 9 Jul 2026 21:45:15 +0200 Subject: [PATCH 05/15] chore: update controller operation summary --- api/src/controllers/application.controller.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index 7a3d22bbb0f..bc2d1d24242 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -330,8 +330,7 @@ export class ApplicationController { @Post('bulk-update/upload-url') @ApiOperation({ - summary: - 'Allows the user to upload the bulk upload CSV to a private S3 bucket', + summary: 'Generates an presigned URL link to a private S3 bucket', operationId: 'uploadBulkUpdate', }) @ApiOkResponse({ type: Boolean }) From 341f8a6afe6ada3e506aa90b24fae354c97a407b Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Thu, 9 Jul 2026 21:45:41 +0200 Subject: [PATCH 06/15] chore: update bulk operation DTO --- .../applications/application-bulk-upload.dto.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/api/src/dtos/applications/application-bulk-upload.dto.ts b/api/src/dtos/applications/application-bulk-upload.dto.ts index f04ad26544c..ee22ce974a9 100644 --- a/api/src/dtos/applications/application-bulk-upload.dto.ts +++ b/api/src/dtos/applications/application-bulk-upload.dto.ts @@ -1,20 +1,29 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; -import { IsDefined, MinLength, MaxLength, IsString } from 'class-validator'; +import { IsDefined, MinLength, IsString, IsUUID } from 'class-validator'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; export class ApplicationBulkUpload { @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) @ApiProperty() - uploadUrl: string; + listingId: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiProperty() + userId: string; @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) @ApiProperty() - s3Key: string; + uploadUrl: string; } From c525147e346237e6518057359e44288a30a57d30 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Thu, 9 Jul 2026 21:59:26 +0200 Subject: [PATCH 07/15] chore: update dto naming convention --- api/src/controllers/application.controller.ts | 2 ++ ...plication-bulk-upload.dto.ts => application-bulk-url.dto.ts} | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) rename api/src/dtos/applications/{application-bulk-upload.dto.ts => application-bulk-url.dto.ts} (96%) diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index bc2d1d24242..15e156fb4b1 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -55,6 +55,7 @@ import { PublicAppsViewQueryParams } from '../dtos/applications/public-apps-view import { PublicAppsViewResponse } from '../dtos/applications/public-apps-view-response.dto'; import { ApplicationBulkUploadService } from '../services/application-bulk-upload.service'; import { ApplicationBulkUpload } from '../dtos/applications/application-bulk-upload.dto'; +import { ApplicationBulkUrl } from '../dtos/applications/application-bulk-url.dto'; @Controller('applications') @ApiTags('applications') @@ -335,6 +336,7 @@ export class ApplicationController { }) @ApiOkResponse({ type: Boolean }) async uploadBulkUpdate(@Body() dto: ApplicationBulkUpload): Promise { + @Body() dto: ApplicationBulkUrl, return this.applicationBulkUploadService.uploadUrl(dto); } diff --git a/api/src/dtos/applications/application-bulk-upload.dto.ts b/api/src/dtos/applications/application-bulk-url.dto.ts similarity index 96% rename from api/src/dtos/applications/application-bulk-upload.dto.ts rename to api/src/dtos/applications/application-bulk-url.dto.ts index ee22ce974a9..a7045a2df26 100644 --- a/api/src/dtos/applications/application-bulk-upload.dto.ts +++ b/api/src/dtos/applications/application-bulk-url.dto.ts @@ -3,7 +3,7 @@ import { Expose } from 'class-transformer'; import { IsDefined, MinLength, IsString, IsUUID } from 'class-validator'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; -export class ApplicationBulkUpload { +export class ApplicationBulkUrl { @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) From 2dfaa1fbd68ba727ac2760f56460ae69ffb39083 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Thu, 9 Jul 2026 21:59:52 +0200 Subject: [PATCH 08/15] chore: update current service method for new incoming logic --- api/src/controllers/application.controller.ts | 9 ++-- .../application-bulk-upload.service.ts | 43 +++++++++---------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index 15e156fb4b1..32e58c52852 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -53,8 +53,10 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; 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 { ApplicationBulkUpload } from '../dtos/applications/application-bulk-upload.dto'; +import { + ApplicationBulkPresignedUrl, + ApplicationBulkUploadService, +} from '../services/application-bulk-upload.service'; import { ApplicationBulkUrl } from '../dtos/applications/application-bulk-url.dto'; @Controller('applications') @@ -335,8 +337,9 @@ export class ApplicationController { operationId: 'uploadBulkUpdate', }) @ApiOkResponse({ type: Boolean }) - async uploadBulkUpdate(@Body() dto: ApplicationBulkUpload): Promise { + async uploadBulkUpdate( @Body() dto: ApplicationBulkUrl, + ): Promise { return this.applicationBulkUploadService.uploadUrl(dto); } diff --git a/api/src/services/application-bulk-upload.service.ts b/api/src/services/application-bulk-upload.service.ts index 39c4642b31f..1491372a092 100644 --- a/api/src/services/application-bulk-upload.service.ts +++ b/api/src/services/application-bulk-upload.service.ts @@ -3,7 +3,6 @@ import { Injectable, StreamableFile, NotFoundException, - BadRequestException, } from '@nestjs/common'; import { ApplicationStatusEnum } from '@prisma/client'; import fs, { createReadStream } from 'fs'; @@ -20,18 +19,17 @@ 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 { ApplicationBulkUpload } from '../dtos/applications/application-bulk-upload.dto'; -import { doJurisdictionHaveFeatureFlagSet } from 'src/utilities/feature-flag-utilities'; -import { FeatureFlagEnum } from 'src/enums/feature-flags/feature-flags-enum'; -import { Jurisdiction } from 'src/dtos/jurisdictions/jurisdiction.dto'; +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'; const NUMBER_TO_PAGINATE_BY = 500; -const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; -const BULK_UPLOAD_KEY_PATTERN = new RegExp( - `^bulk-application-updates-(${UUID})-(${UUID})-(.+)\\.csv$`, - 'i', -); +export type ApplicationBulkPresignedUrl = { + key: string; + presignedUrl: string; +}; @Injectable() export class ApplicationBulkUploadService { @@ -306,17 +304,10 @@ export class ApplicationBulkUploadService { return stringData; } - async uploadUrl(dto: ApplicationBulkUpload) { - const { s3Key, uploadUrl } = dto; - - const match = BULK_UPLOAD_KEY_PATTERN.exec(s3Key); - if (!match) { - throw new BadRequestException( - 's3Key must match format: bulk-application-updates-{listingId}-{userId}-{time-generated}.csv', - ); - } - - const [, listingId, userId] = match; + async uploadUrl( + dto: ApplicationBulkUrl, + ): Promise { + const { userId, listingId, uploadUrl } = dto; const listingData = await this.prisma.listings.findUnique({ select: { @@ -365,7 +356,15 @@ export class ApplicationBulkUploadService { ); } - return true; + const s3KeyTemplate = `bulk-application-updates-${listingId}-${userId}-${formatLocalDate( + new Date(), + 'YYYY-MM-DD-hh:mm:ss', + )}.csv`; + + return { + key: s3KeyTemplate, + presignedUrl: '', + }; } async authorizeExport(user, listingId): Promise { From 72db937524456dffafc869f0dae37db9e4341c28 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Thu, 9 Jul 2026 22:01:42 +0200 Subject: [PATCH 09/15] chore: add developer comments for handoff --- api/src/services/application-bulk-upload.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/services/application-bulk-upload.service.ts b/api/src/services/application-bulk-upload.service.ts index 1491372a092..eb6d1372b7f 100644 --- a/api/src/services/application-bulk-upload.service.ts +++ b/api/src/services/application-bulk-upload.service.ts @@ -304,6 +304,7 @@ export class ApplicationBulkUploadService { return stringData; } + // TODO Yazeed - Integrate the uploadUrl method with S3 presigned URL generation logic async uploadUrl( dto: ApplicationBulkUrl, ): Promise { @@ -363,7 +364,7 @@ export class ApplicationBulkUploadService { return { key: s3KeyTemplate, - presignedUrl: '', + presignedUrl: '', // TODO: Yazeed - Add S3 generated presigned URL }; } From e837c201c7af5204d545ff97ffd684eb328c5b0c Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 17:54:57 +0200 Subject: [PATCH 10/15] chore: update the endpoint response decorator and response DTO --- api/src/controllers/application.controller.ts | 8 +++----- .../application-bulk-presigned-url.dto.ts | 18 ++++++++++++++++++ .../application-bulk-upload.service.ts | 6 +----- 3 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 api/src/dtos/applications/application-bulk-presigned-url.dto.ts diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index 32e58c52852..79fde1d5e5b 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -53,11 +53,9 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; 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 { - ApplicationBulkPresignedUrl, - ApplicationBulkUploadService, -} from '../services/application-bulk-upload.service'; +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') @@ -336,7 +334,7 @@ export class ApplicationController { summary: 'Generates an presigned URL link to a private S3 bucket', operationId: 'uploadBulkUpdate', }) - @ApiOkResponse({ type: Boolean }) + @ApiOkResponse({ type: ApplicationBulkPresignedUrl }) async uploadBulkUpdate( @Body() dto: ApplicationBulkUrl, ): Promise { 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/services/application-bulk-upload.service.ts b/api/src/services/application-bulk-upload.service.ts index eb6d1372b7f..ff325fa8b56 100644 --- a/api/src/services/application-bulk-upload.service.ts +++ b/api/src/services/application-bulk-upload.service.ts @@ -23,14 +23,10 @@ import { ApplicationBulkUrl } from '../dtos/applications/application-bulk-url.dt 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'; const NUMBER_TO_PAGINATE_BY = 500; -export type ApplicationBulkPresignedUrl = { - key: string; - presignedUrl: string; -}; - @Injectable() export class ApplicationBulkUploadService { private dateFormat = 'MM-DD-YYYY hh:mm:ssA z'; From 10c017d9de0401b6d1a4948d7a5c00f09b3c4f42 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 17:58:45 +0200 Subject: [PATCH 11/15] chore: remove the uploadUrl from the incoming DTO --- api/src/dtos/applications/application-bulk-url.dto.ts | 7 ------- api/src/services/application-bulk-upload.service.ts | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/api/src/dtos/applications/application-bulk-url.dto.ts b/api/src/dtos/applications/application-bulk-url.dto.ts index a7045a2df26..6e7afe2f953 100644 --- a/api/src/dtos/applications/application-bulk-url.dto.ts +++ b/api/src/dtos/applications/application-bulk-url.dto.ts @@ -19,11 +19,4 @@ export class ApplicationBulkUrl { @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) @ApiProperty() userId: string; - - @Expose() - @IsString({ groups: [ValidationsGroupsEnum.default] }) - @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) - @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) - @ApiProperty() - uploadUrl: string; } diff --git a/api/src/services/application-bulk-upload.service.ts b/api/src/services/application-bulk-upload.service.ts index ff325fa8b56..5bc38808ac1 100644 --- a/api/src/services/application-bulk-upload.service.ts +++ b/api/src/services/application-bulk-upload.service.ts @@ -304,10 +304,11 @@ export class ApplicationBulkUploadService { async uploadUrl( dto: ApplicationBulkUrl, ): Promise { - const { userId, listingId, uploadUrl } = dto; + const { userId, listingId } = dto; const listingData = await this.prisma.listings.findUnique({ select: { + // id: true, jurisdictionId: true, jurisdictions: { select: { From 47dc42277cdb5b82a32ee23cd73fe045380592c0 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 18:06:48 +0200 Subject: [PATCH 12/15] chore: update the s3 template key formatting --- api/src/services/application-bulk-upload.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api/src/services/application-bulk-upload.service.ts b/api/src/services/application-bulk-upload.service.ts index 5bc38808ac1..38b1123db3f 100644 --- a/api/src/services/application-bulk-upload.service.ts +++ b/api/src/services/application-bulk-upload.service.ts @@ -354,10 +354,7 @@ export class ApplicationBulkUploadService { ); } - const s3KeyTemplate = `bulk-application-updates-${listingId}-${userId}-${formatLocalDate( - new Date(), - 'YYYY-MM-DD-hh:mm:ss', - )}.csv`; + const s3KeyTemplate = `bulk-application-updates-${listingId}-${userId}-${new Date().toISOString()}`; return { key: s3KeyTemplate, From a9d3466b51986658dda7f5324e761d8e99485cb6 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Tue, 14 Jul 2026 16:59:01 +0200 Subject: [PATCH 13/15] chore: add new upload URL for private bucket s3 service method --- .../application-bulk-upload.service.ts | 7 +++++- api/src/services/s3.service.ts | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/api/src/services/application-bulk-upload.service.ts b/api/src/services/application-bulk-upload.service.ts index 38b1123db3f..e6c737f9c1a 100644 --- a/api/src/services/application-bulk-upload.service.ts +++ b/api/src/services/application-bulk-upload.service.ts @@ -24,6 +24,7 @@ import { doJurisdictionHaveFeatureFlagSet } from '../utilities/feature-flag-util 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; @@ -35,6 +36,7 @@ export class ApplicationBulkUploadService { private prisma: PrismaService, private listingService: ListingService, private permissionService: PermissionService, + private s3Service: S3Service, ) {} private formatApplicationStatus(statusEnum: ApplicationStatusEnum): string { @@ -355,10 +357,13 @@ export class ApplicationBulkUploadService { } const s3KeyTemplate = `bulk-application-updates-${listingId}-${userId}-${new Date().toISOString()}`; + const presignedUrl = await this.s3Service.uploadURLForPrivate( + s3KeyTemplate, + ); return { key: s3KeyTemplate, - presignedUrl: '', // TODO: Yazeed - Add S3 generated presigned URL + presignedUrl: presignedUrl, }; } 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}`; } From 8f9aee565ada8442f3f7ee2641a2dd5de40d6ca2 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Tue, 14 Jul 2026 17:14:20 +0200 Subject: [PATCH 14/15] fix: add missing providers --- api/src/modules/application-bulk-upload.module.ts | 3 ++- api/test/unit/services/application-bulk-upload.service.spec.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) 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/test/unit/services/application-bulk-upload.service.spec.ts b/api/test/unit/services/application-bulk-upload.service.spec.ts index 5ae351a6af7..33552f4e634 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,7 @@ describe('Testing application bulk upload services', () => { provide: PermissionService, useValue: { canOrThrow: canOrThrowMock }, }, + S3Service, ], }).compile(); From b0766679561ce7792b490a1cd2fd50414065158d Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Tue, 14 Jul 2026 17:23:45 +0200 Subject: [PATCH 15/15] fix: fix testing module initialisation --- .../services/application-bulk-upload.service.spec.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 33552f4e634..aae2a7bedd7 100644 --- a/api/test/unit/services/application-bulk-upload.service.spec.ts +++ b/api/test/unit/services/application-bulk-upload.service.spec.ts @@ -92,7 +92,16 @@ describe('Testing application bulk upload services', () => { provide: PermissionService, useValue: { canOrThrow: canOrThrowMock }, }, - S3Service, + { + provide: S3Service, + useValue: { + uploadToPrivate: jest.fn(), + urlForPrivate: jest.fn(), + uploadURLForPublic: jest.fn(), + uploadURLForPrivate: jest.fn(), + urlForPublic: jest.fn(), + }, + }, ], }).compile();