Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d282965
chore: setup a DTO for the bulk upload s3 endpoint
matzduniuk Jul 6, 2026
a2c6e37
chore: setup new application controller endpoint
matzduniuk Jul 8, 2026
ab92cf5
chore: setup a base bulk application service method
matzduniuk Jul 8, 2026
d0236a1
chore: update listing feature flag exception type
matzduniuk Jul 9, 2026
12574e3
chore: update controller operation summary
matzduniuk Jul 9, 2026
341f8a6
chore: update bulk operation DTO
matzduniuk Jul 9, 2026
c525147
chore: update dto naming convention
matzduniuk Jul 9, 2026
2dfaa1f
chore: update current service method for new incoming logic
matzduniuk Jul 9, 2026
72db937
chore: add developer comments for handoff
matzduniuk Jul 9, 2026
e837c20
chore: update the endpoint response decorator and response DTO
matzduniuk Jul 13, 2026
10c017d
chore: remove the uploadUrl from the incoming DTO
matzduniuk Jul 13, 2026
47dc422
chore: update the s3 template key formatting
matzduniuk Jul 13, 2026
a9d3466
chore: add new upload URL for private bucket s3 service method
matzduniuk Jul 14, 2026
89ee266
Merge branch 'main' into 6377/s3-bucket-integration
matzduniuk Jul 14, 2026
8f9aee5
fix: add missing providers
matzduniuk Jul 14, 2026
b076667
fix: fix testing module initialisation
matzduniuk Jul 14, 2026
9049351
Merge branch '6377/s3-bucket-integration' of https://github.com/bloom…
matzduniuk Jul 14, 2026
5946255
Merge branch 'main' into 6377/s3-bucket-integration
matzduniuk Jul 15, 2026
5c2cadd
Merge branch 'main' into 6377/s3-bucket-integration
matzduniuk Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/src/controllers/application.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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<ApplicationBulkPresignedUrl> {
return this.applicationBulkUploadService.uploadUrl(dto);
}

@Delete()
@ApiOperation({ summary: 'Delete application by id', operationId: 'delete' })
@ApiOkResponse({ type: SuccessDTO })
Expand Down
18 changes: 18 additions & 0 deletions api/src/dtos/applications/application-bulk-presigned-url.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 22 additions & 0 deletions api/src/dtos/applications/application-bulk-url.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 2 additions & 1 deletion api/src/modules/application-bulk-upload.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
72 changes: 72 additions & 0 deletions api/src/services/application-bulk-upload.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -30,6 +36,7 @@ export class ApplicationBulkUploadService {
private prisma: PrismaService,
private listingService: ListingService,
private permissionService: PermissionService,
private s3Service: S3Service,
) {}

private formatApplicationStatus(statusEnum: ApplicationStatusEnum): string {
Expand Down Expand Up @@ -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<ApplicationBulkPresignedUrl> {
Comment thread
matzduniuk marked this conversation as resolved.
const { userId, listingId } = dto;

const listingData = await this.prisma.listings.findUnique({
select: {
// id: true,
jurisdictionId: true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should specify the listing id is responded here so we aren't returning the full listing object

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are already limiting the listing response object to only have the jurisdictionId and linked jurisdiction feature flags array

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<void> {
/**
* Checking authorization for each application is very expensive.
Expand Down
24 changes: 24 additions & 0 deletions api/src/services/s3.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,30 @@ export class S3Service {
return getSignedUrl(this.s3Client, command);
}

async uploadURLForPrivate(
key: string,
metadata?: CreateS3UploadMetadata,
expiresIn: number = 60 * 5,
): Promise<string> {
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}`;
}
Expand Down
11 changes: 11 additions & 0 deletions api/test/unit/services/application-bulk-upload.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down
Loading