From 5caa36f622b77691e073ff675c905e991ec5183b Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 12:09:03 +0200 Subject: [PATCH 01/54] chore: setup new schema definitions for enums and jobs table --- api/prisma/schema.prisma | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d7e87366091..caf7270ff55 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -2238,6 +2238,23 @@ model UserRoleSnapshot { @@map("user_role_snapshot") } +model BackgroundJob { + id String @id @default(uuid()) @db.Uuid + listingId String @map("listing_id") @db.Uuid + requestedByUserId String @map("requested_by_user_id") @db.Uuid + status BackgroundJobStatusEnum + totalRecords Int? @map("total_records") + inputS3Key String @map("input_s3_key") + errorMessage String? @map("error_message") + errorRow Int? @map("error_row") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + completedAt DateTime? @map("completed-at") + + @@index([listingId]) + @@map("background_job") +} + // --- View Section --- // view ApplicationFlaggedSetPossibilities { key String @@ -2617,3 +2634,9 @@ enum YesNoEnum { @@map("yes_no_enum") } + +enum BackgroundJobStatusEnum { + processing + completed + failed +} From dd6dd6fca143e502348685f2ffcd7825edfa7b4b Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 12:09:38 +0200 Subject: [PATCH 02/54] chore: add basic structure for new background jobs service, module and controller --- .../controllers/background-jobs.controller.ts | 7 +++ api/src/modules/background-jobs.module.ts | 12 ++++++ api/src/services/background-jobs.service.ts | 43 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 api/src/controllers/background-jobs.controller.ts create mode 100644 api/src/modules/background-jobs.module.ts create mode 100644 api/src/services/background-jobs.service.ts diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts new file mode 100644 index 00000000000..a35f4a0b495 --- /dev/null +++ b/api/src/controllers/background-jobs.controller.ts @@ -0,0 +1,7 @@ +import { Controller } from '@nestjs/common'; +import { BackgroundJobsService } from 'src/services/background-jobs.service'; + +@Controller('jobs') +export class BackgroundJobsController { + constructor(private readonly backgroundJobsService: BackgroundJobsService) {} +} diff --git a/api/src/modules/background-jobs.module.ts b/api/src/modules/background-jobs.module.ts new file mode 100644 index 00000000000..012dffc4b80 --- /dev/null +++ b/api/src/modules/background-jobs.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { BackgroundJobsController } from 'src/controllers/background-jobs.controller'; +import { BackgroundJobsService } from 'src/services/background-jobs.service'; +import { PrismaModule } from './prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [BackgroundJobsService], + controllers: [BackgroundJobsController], + exports: [BackgroundJobsService], +}) +export class BackgroundJobsModule {} diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts new file mode 100644 index 00000000000..c920179c236 --- /dev/null +++ b/api/src/services/background-jobs.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Injectable() +export class BackgroundJobsService { + constructor(private readonly prismaService: PrismaService) {} + + /** + * Creates an instance of a background job runner for a listing + * @param listingId - Id for the listing on which data the runner will be working on + * @param inputS3Key - S3 key for accessing data store in the AWS bucket + * @param requestedByUserId - Id of the uer who requested the process creation + */ + create() { + return; + } + + /** + * Finds an instance of a job by its ID + * @param jobId - Id of the job to return data on + * @returns Details on the requested job + */ + getById() { + return; + } + + /** + * Return latest job with a processing status else null + * @param listingId - Id of the listing for which the job should be retrieved + * @returns Details on the currently processed job for the desired listing + */ + findActiveForListing() { + return; + } + + /** + * Returns true if there is any job running (i.e. in status other than completed or failed) + * @returns True if any active job exists (false otherwise) + */ + findActiveJob() { + return; + } +} From 033f9ee2c618de23a0c1b0d6dd658580353c884a Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 13:07:20 +0200 Subject: [PATCH 03/54] chore: add new migration file --- .../migration.sql | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 api/prisma/migrations/63_add_background_jobs_table/migration.sql diff --git a/api/prisma/migrations/63_add_background_jobs_table/migration.sql b/api/prisma/migrations/63_add_background_jobs_table/migration.sql new file mode 100644 index 00000000000..9e434f311c5 --- /dev/null +++ b/api/prisma/migrations/63_add_background_jobs_table/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "BackgroundJobStatusEnum" AS ENUM ('processing', 'completed', 'failed'); + +-- CreateTable +CREATE TABLE "background_job" ( + "id" UUID NOT NULL, + "listing_id" UUID NOT NULL, + "requested_by_user_id" UUID NOT NULL, + "status" "BackgroundJobStatusEnum" NOT NULL, + "total_records" INTEGER, + "input_s3_key" TEXT NOT NULL, + "error_message" TEXT, + "error_row" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "completed-at" TIMESTAMP(3), + + CONSTRAINT "background_job_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "background_job_listing_id_idx" ON "background_job"("listing_id"); From 4647fa4fd31ea353177b7c9cf6ae3fb2732fcbdd Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 15:02:44 +0200 Subject: [PATCH 04/54] chore: setup background jobs DTOs classes --- .../background-job-create.dto.ts | 19 +++++ .../background-jobs/background-job.dto.ts | 70 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 api/src/dtos/background-jobs/background-job-create.dto.ts create mode 100644 api/src/dtos/background-jobs/background-job.dto.ts diff --git a/api/src/dtos/background-jobs/background-job-create.dto.ts b/api/src/dtos/background-jobs/background-job-create.dto.ts new file mode 100644 index 00000000000..6a0da873b0a --- /dev/null +++ b/api/src/dtos/background-jobs/background-job-create.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, IsString, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from 'src/enums/shared/validation-groups-enum'; + +export class BackgroundJobCreate { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + listingId: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + inputS3Key: string; +} diff --git a/api/src/dtos/background-jobs/background-job.dto.ts b/api/src/dtos/background-jobs/background-job.dto.ts new file mode 100644 index 00000000000..44a74dca622 --- /dev/null +++ b/api/src/dtos/background-jobs/background-job.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsDefined, + IsDate, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; +import { BackgroundJobStatusEnum } from '@prisma/client'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; + +export class BackgroundJob extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + listingId: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + requestedByUserId: string; + + @Expose() + @IsEnum(BackgroundJobStatusEnum, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: BackgroundJobStatusEnum, + enumName: 'BackgroundJobStatusEnum', + }) + status: BackgroundJobStatusEnum; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsInt({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + totalRecords?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + inputS3Key: string; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + errorMessage?: string; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsInt({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + errorRow?: number; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + completedAt?: Date; +} From a414a2dfe4207e97968c59f072691daa3ffd54d6 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 15:04:12 +0200 Subject: [PATCH 05/54] chore: add new permission policies for new jobs service --- api/src/permission-configs/permission_policy.csv | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/src/permission-configs/permission_policy.csv b/api/src/permission-configs/permission_policy.csv index f2e62bc0a89..3f4c0aeeb43 100644 --- a/api/src/permission-configs/permission_policy.csv +++ b/api/src/permission-configs/permission_policy.csv @@ -95,6 +95,11 @@ p, jurisdictionAdmin, unitRentType, true, read p, limitedJurisdictionAdmin, unitRentType, true, read p, partner, unitRentType, true, read +p, admin, jobs, true, .* +p, supportAdmin, jobs, true, .* +p, jurisdictionAdmin, jobs, true, .* +p, limitedJurisdictionAdmin, jobs, true, .* +p, partner, jobs, true, .* p, admin, applicationMethod, true, .* p, supportAdmin, applicationMethod, true, .* From 279a13aa8454cd254b3ea76d0cefd33082c47c03 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 15:06:25 +0200 Subject: [PATCH 06/54] chore: implement the background jobs service --- api/src/services/background-jobs.service.ts | 102 +++++++++++++++++--- 1 file changed, 89 insertions(+), 13 deletions(-) diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index c920179c236..5ca01b2aa75 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -1,18 +1,70 @@ -import { Injectable } from '@nestjs/common'; +import { + ConflictException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from './prisma.service'; +import { BackgroundJobStatusEnum } from '@prisma/client'; +import { mapTo } from '../utilities/mapTo'; +import { BackgroundJob } from '../dtos/background-jobs/background-job.dto'; +import { S3Service } from './s3.service'; +import { User } from 'src/dtos/users/user.dto'; +import { BackgroundJobCreate } from 'src/dtos/background-jobs/background-job-create.dto'; +import { PermissionService } from './permission.service'; +import { permissionActions } from 'src/enums/permissions/permission-actions-enum'; @Injectable() export class BackgroundJobsService { - constructor(private readonly prismaService: PrismaService) {} + constructor( + private readonly prismaService: PrismaService, + private readonly s3Service: S3Service, + private readonly permissionService: PermissionService, + ) {} /** * Creates an instance of a background job runner for a listing - * @param listingId - Id for the listing on which data the runner will be working on - * @param inputS3Key - S3 key for accessing data store in the AWS bucket - * @param requestedByUserId - Id of the uer who requested the process creation + * @param dto - background job creation DTO + * @param requestingUser - Data on requesting user + * @returns The newly created background job */ - create() { - return; + async create( + dto: BackgroundJobCreate, + requestingUser: User, + ): Promise { + const { listingId, inputS3Key } = dto; + const requestedByUserId = requestingUser.id; + + await this.permissionService.canOrThrow( + requestingUser, + 'job', + permissionActions.create, + ); + + const hasActiveListing = !!this.findActiveForListing(listingId); + + if (hasActiveListing) { + throw new ConflictException( + `Listing with ID: ${listingId} has a currently running job assigned`, + ); + } + + // TODO: Integrate the connection with the S3 service + + const backgroundJob = await this.prismaService.backgroundJob.create({ + data: { + listingId, + requestedByUserId, + inputS3Key, + status: BackgroundJobStatusEnum.processing, + }, + }); + + if (!backgroundJob) { + throw new InternalServerErrorException('Failed to create new job record'); + } + + return mapTo(BackgroundJob, backgroundJob); } /** @@ -20,8 +72,18 @@ export class BackgroundJobsService { * @param jobId - Id of the job to return data on * @returns Details on the requested job */ - getById() { - return; + async getById(jobId: string): Promise { + try { + const jobData = this.prismaService.backgroundJob.findFirstOrThrow({ + where: { + id: jobId, + }, + }); + + return mapTo(BackgroundJob, jobData); + } catch { + throw new NotFoundException(`Job with id: ${jobId} was not found`); + } } /** @@ -29,15 +91,29 @@ export class BackgroundJobsService { * @param listingId - Id of the listing for which the job should be retrieved * @returns Details on the currently processed job for the desired listing */ - findActiveForListing() { - return; + async findActiveForListing(listingId: string): Promise { + const activeJob = this.prismaService.backgroundJob.findFirst({ + where: { + listingId: listingId, + status: BackgroundJobStatusEnum.processing, + }, + }); + + return activeJob ? mapTo(BackgroundJob, activeJob) : null; } /** * Returns true if there is any job running (i.e. in status other than completed or failed) * @returns True if any active job exists (false otherwise) */ - findActiveJob() { - return; + async findActiveJob(): Promise { + const activeJob = this.prismaService.backgroundJob.findFirst({ + select: {}, + where: { + status: BackgroundJobStatusEnum.processing, + }, + }); + + return !!activeJob; } } From 35d8fec8c54bdd3f5371e156046126fad78cc449 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 16:14:01 +0200 Subject: [PATCH 07/54] chore: implement the jobs controller --- .../controllers/background-jobs.controller.ts | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index a35f4a0b495..e3fd519aa79 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -1,7 +1,68 @@ -import { Controller } from '@nestjs/common'; -import { BackgroundJobsService } from 'src/services/background-jobs.service'; +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Request, + UseGuards, +} from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Request as ExpressRequest } from 'express'; +import { ApiKeyGuard } from '../guards/api-key.guard'; +import { BackgroundJobsService } from '../services/background-jobs.service'; +import { BackgroundJobCreate } from '../dtos/background-jobs/background-job-create.dto'; +import { mapTo } from '../utilities/mapTo'; +import { User } from '../dtos/users/user.dto'; +import { BackgroundJob } from '../dtos/background-jobs/background-job.dto'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { JwtAuthGuard } from '../guards/jwt.guard'; @Controller('jobs') +@ApiTags('jobs') +@PermissionTypeDecorator('jobs') +@UseGuards(ApiKeyGuard, JwtAuthGuard) export class BackgroundJobsController { constructor(private readonly backgroundJobsService: BackgroundJobsService) {} + + @Post() + @ApiOperation({ + summary: 'Creates a new background job record in the database', + operationId: 'createBackgroundJob', + }) + @UseGuards(ApiKeyGuard) + @ApiOkResponse({ type: BackgroundJob }) + public async createBackgroundJob( + @Request() req: ExpressRequest, + @Body() dto: BackgroundJobCreate, + ): Promise { + return await this.backgroundJobsService.create( + dto, + mapTo(User, req['user']), + ); + } + + @Get() + @ApiOperation({ + summary: 'Get an active background job for a listing', + operationId: 'findActiveJobForListing', + }) + @ApiOkResponse({ type: BackgroundJob }) + public async getListingActiveJob( + @Query('listingId') listingId: string, + ): Promise { + return await this.backgroundJobsService.findActiveForListing(listingId); + } + + @Get(':jobId') + @ApiOperation({ + summary: 'Get a background job data by its ID', + operationId: 'getBackgroundJob', + }) + @UseGuards(ApiKeyGuard) + @ApiOkResponse({ type: BackgroundJob }) + public async getJobById(@Param('id') jobId: string): Promise { + return await this.backgroundJobsService.getById(jobId); + } } From c13923e65a76755cefa32e2269195f0037d6e86a Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 16:17:11 +0200 Subject: [PATCH 08/54] fix: add missing await statements --- api/src/services/background-jobs.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index 5ca01b2aa75..b8fa62151c0 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -41,7 +41,7 @@ export class BackgroundJobsService { permissionActions.create, ); - const hasActiveListing = !!this.findActiveForListing(listingId); + const hasActiveListing = !!(await this.findActiveForListing(listingId)); if (hasActiveListing) { throw new ConflictException( @@ -92,7 +92,7 @@ export class BackgroundJobsService { * @returns Details on the currently processed job for the desired listing */ async findActiveForListing(listingId: string): Promise { - const activeJob = this.prismaService.backgroundJob.findFirst({ + const activeJob = await this.prismaService.backgroundJob.findFirst({ where: { listingId: listingId, status: BackgroundJobStatusEnum.processing, From 2715a08d539315914221f1964ba20b8bee3e7bc2 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 16:18:13 +0200 Subject: [PATCH 09/54] chore: update background jobs module to include new imports --- api/src/modules/background-jobs.module.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/src/modules/background-jobs.module.ts b/api/src/modules/background-jobs.module.ts index 012dffc4b80..fac5f2aba1b 100644 --- a/api/src/modules/background-jobs.module.ts +++ b/api/src/modules/background-jobs.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; -import { BackgroundJobsController } from 'src/controllers/background-jobs.controller'; -import { BackgroundJobsService } from 'src/services/background-jobs.service'; import { PrismaModule } from './prisma.module'; +import { S3Module } from './s3.module'; +import { PermissionModule } from './permission.module'; +import { BackgroundJobsController } from '../controllers/background-jobs.controller'; +import { BackgroundJobsService } from '../services/background-jobs.service'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, S3Module, PermissionModule], providers: [BackgroundJobsService], controllers: [BackgroundJobsController], exports: [BackgroundJobsService], From c6862abf9c7d1a18a815cb956b0d6f6af7fa24c9 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 16:18:39 +0200 Subject: [PATCH 10/54] chore: update prisma schema linting --- api/prisma/schema.prisma | 232 +++++++++++++++++++-------------------- 1 file changed, 116 insertions(+), 116 deletions(-) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index caf7270ff55..db28c320d5e 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -376,57 +376,57 @@ model Applications { createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) - acceptedTerms Boolean? @map("accepted_terms") - accessibilityId String? @unique() @map("accessibility_id") @db.Uuid - accessibleUnitWaitlistNumber Int? @map("accessible_unit_waitlist_number") - additionalPhone Boolean? @map("additional_phone") - additionalPhoneNumber String? @map("additional_phone_number") - additionalPhoneNumberType String? @map("additional_phone_number_type") - alternateAddressId String? @unique() @map("alternate_address_id") @db.Uuid - alternateContactId String? @unique() @map("alternate_contact_id") @db.Uuid - applicantId String? @unique() @map("applicant_id") @db.Uuid - appUrl String? @map("app_url") - confirmationCode String @map("confirmation_code") - contactPreferences String[] @map("contact_preferences") - conventionalUnitWaitlistNumber Int? @map("conventional_unit_waitlist_number") - deletedAt DateTime? @map("deleted_at") @db.Timestamp(6) - demographicsId String? @unique() @map("demographics_id") @db.Uuid + acceptedTerms Boolean? @map("accepted_terms") + accessibilityId String? @unique() @map("accessibility_id") @db.Uuid + accessibleUnitWaitlistNumber Int? @map("accessible_unit_waitlist_number") + additionalPhone Boolean? @map("additional_phone") + additionalPhoneNumber String? @map("additional_phone_number") + additionalPhoneNumberType String? @map("additional_phone_number_type") + alternateAddressId String? @unique() @map("alternate_address_id") @db.Uuid + alternateContactId String? @unique() @map("alternate_contact_id") @db.Uuid + applicantId String? @unique() @map("applicant_id") @db.Uuid + appUrl String? @map("app_url") + confirmationCode String @map("confirmation_code") + contactPreferences String[] @map("contact_preferences") + conventionalUnitWaitlistNumber Int? @map("conventional_unit_waitlist_number") + deletedAt DateTime? @map("deleted_at") @db.Timestamp(6) + demographicsId String? @unique() @map("demographics_id") @db.Uuid // Signifies when PII data should be cleared - expireAfter DateTime? @map("expire_after") @db.Timestamp(6) + expireAfter DateTime? @map("expire_after") @db.Timestamp(6) // If a user is deleted we want the historic data of what applications were associated with that user - historicalUserId String? @map("historical_user_id") @db.Uuid - householdExpectingChanges Boolean? @map("household_expecting_changes") - householdSize Int? @map("household_size") - householdStudent Boolean? @map("household_student") - reasonableAccommodations String? @map("reasonable_accommodations") - housingStatus String? @map("housing_status") - income String? - incomePeriod IncomePeriodEnum? @map("income_period") - incomeVouchers Boolean? @map("income_vouchers") + historicalUserId String? @map("historical_user_id") @db.Uuid + householdExpectingChanges Boolean? @map("household_expecting_changes") + householdSize Int? @map("household_size") + householdStudent Boolean? @map("household_student") + reasonableAccommodations String? @map("reasonable_accommodations") + housingStatus String? @map("housing_status") + income String? + incomePeriod IncomePeriodEnum? @map("income_period") + incomeVouchers Boolean? @map("income_vouchers") // This application is the most recent for the user - isNewest Boolean? @default(false) @map("is_newest") - language LanguagesEnum? - listingId String? @map("listing_id") @db.Uuid - mailingAddressId String? @unique() @map("mailing_address_id") @db.Uuid - manualLotteryPositionNumber Int? @map("manual_lottery_position_number") + isNewest Boolean? @default(false) @map("is_newest") + language LanguagesEnum? + listingId String? @map("listing_id") @db.Uuid + mailingAddressId String? @unique() @map("mailing_address_id") @db.Uuid + manualLotteryPositionNumber Int? @map("manual_lottery_position_number") // if this field is true then the application is a confirmed duplicate // meaning that the record in the application flagged set table has a status of duplicate - markedAsDuplicate Boolean @default(false) @map("marked_as_duplicate") - preferences Json - programs Json? - reviewStatus ApplicationReviewStatusEnum @default(pending) @map("review_status") - sendMailToMailingAddress Boolean? @map("send_mail_to_mailing_address") - status ApplicationStatusEnum @default(submitted) - applicationDeclineReason ApplicationDeclineReasonEnum? @map("application_decline_reason") - applicationDeclineReasonAdditionalDetails String? @map("application_decline_reason_additional_details") - submissionDate DateTime? @map("submission_date") @db.Timestamptz(6) - submissionType ApplicationSubmissionTypeEnum @map("submission_type") - receivedAt DateTime? @map("received_at") @db.Timestamptz(6) - receivedBy String? @map("received_by") - userId String? @map("user_id") @db.Uuid + markedAsDuplicate Boolean @default(false) @map("marked_as_duplicate") + preferences Json + programs Json? + reviewStatus ApplicationReviewStatusEnum @default(pending) @map("review_status") + sendMailToMailingAddress Boolean? @map("send_mail_to_mailing_address") + status ApplicationStatusEnum @default(submitted) + applicationDeclineReason ApplicationDeclineReasonEnum? @map("application_decline_reason") + applicationDeclineReasonAdditionalDetails String? @map("application_decline_reason_additional_details") + submissionDate DateTime? @map("submission_date") @db.Timestamptz(6) + submissionType ApplicationSubmissionTypeEnum @map("submission_type") + receivedAt DateTime? @map("received_at") @db.Timestamptz(6) + receivedBy String? @map("received_by") + userId String? @map("user_id") @db.Uuid // Used to denote when an application was transferred from another jurisdiction - wasCreatedExternally Boolean @default(false) @map("was_created_externally") - wasPIICleared Boolean? @default(false) @map("was_pii_cleared") + wasCreatedExternally Boolean @default(false) @map("was_created_externally") + wasPIICleared Boolean? @default(false) @map("was_pii_cleared") accessibility Accessibility? @relation(fields: [accessibilityId], references: [id], onDelete: NoAction, onUpdate: NoAction) alternateContact AlternateContact? @relation(fields: [alternateContactId], references: [id], onDelete: NoAction, onUpdate: NoAction) @@ -457,53 +457,53 @@ model ApplicationSnapshot { originalCreatedAt DateTime @map("original_created_at") @db.Timestamp(6) updatedAt DateTime @map("updated_at") @db.Timestamp(6) - acceptedTerms Boolean? @map("accepted_terms") - accessibilitySnapshotId String? @unique() @map("accessibility_snapshot_id") @db.Uuid - accessibleUnitWaitlistNumber Int? @map("accessible_unit_waitlist_number") - additionalPhone Boolean? @map("additional_phone") - additionalPhoneNumber String? @map("additional_phone_number") - additionalPhoneNumberType String? @map("additional_phone_number_type") - alternateAddressSnapshotId String? @unique() @map("alternate_address_snapshot_id") @db.Uuid - alternateContactSnapshotId String? @unique() @map("alternate_contact_snapshot_id") @db.Uuid - applicantSnapshotId String? @unique() @map("applicant_snapshot_id") @db.Uuid - appUrl String? @map("app_url") - confirmationCode String @map("confirmation_code") - contactPreferences String[] @map("contact_preferences") - conventionalUnitWaitlistNumber Int? @map("conventional_unit_waitlist_number") - deletedAt DateTime? @map("deleted_at") @db.Timestamp(6) - demographicSnapshotId String? @unique() @map("demographic_snapshot_id") @db.Uuid + acceptedTerms Boolean? @map("accepted_terms") + accessibilitySnapshotId String? @unique() @map("accessibility_snapshot_id") @db.Uuid + accessibleUnitWaitlistNumber Int? @map("accessible_unit_waitlist_number") + additionalPhone Boolean? @map("additional_phone") + additionalPhoneNumber String? @map("additional_phone_number") + additionalPhoneNumberType String? @map("additional_phone_number_type") + alternateAddressSnapshotId String? @unique() @map("alternate_address_snapshot_id") @db.Uuid + alternateContactSnapshotId String? @unique() @map("alternate_contact_snapshot_id") @db.Uuid + applicantSnapshotId String? @unique() @map("applicant_snapshot_id") @db.Uuid + appUrl String? @map("app_url") + confirmationCode String @map("confirmation_code") + contactPreferences String[] @map("contact_preferences") + conventionalUnitWaitlistNumber Int? @map("conventional_unit_waitlist_number") + deletedAt DateTime? @map("deleted_at") @db.Timestamp(6) + demographicSnapshotId String? @unique() @map("demographic_snapshot_id") @db.Uuid // Signifies when PII data should be cleared - expireAfter DateTime? @map("expire_after") @db.Timestamp(6) - householdExpectingChanges Boolean? @map("household_expecting_changes") - householdSize Int? @map("household_size") - householdStudent Boolean? @map("household_student") - reasonableAccommodations String? @map("reasonable_accommodations") - housingStatus String? @map("housing_status") - income String? - incomePeriod IncomePeriodEnum? @map("income_period") - incomeVouchers Boolean? @map("income_vouchers") + expireAfter DateTime? @map("expire_after") @db.Timestamp(6) + householdExpectingChanges Boolean? @map("household_expecting_changes") + householdSize Int? @map("household_size") + householdStudent Boolean? @map("household_student") + reasonableAccommodations String? @map("reasonable_accommodations") + housingStatus String? @map("housing_status") + income String? + incomePeriod IncomePeriodEnum? @map("income_period") + incomeVouchers Boolean? @map("income_vouchers") // This application is the most recent for the user - isNewest Boolean? @default(false) @map("is_newest") - language LanguagesEnum? - listingId String? @map("listing_id") @db.Uuid - mailingAddressSnapshotId String? @unique() @map("mailing_address_snapshot_id") @db.Uuid - manualLotteryPositionNumber Int? @map("manual_lottery_position_number") + isNewest Boolean? @default(false) @map("is_newest") + language LanguagesEnum? + listingId String? @map("listing_id") @db.Uuid + mailingAddressSnapshotId String? @unique() @map("mailing_address_snapshot_id") @db.Uuid + manualLotteryPositionNumber Int? @map("manual_lottery_position_number") // if this field is true then the application is a confirmed duplicate // meaning that the record in the application flagged set table has a status of duplicate - markedAsDuplicate Boolean @default(false) @map("marked_as_duplicate") - preferences Json - programs Json? - reviewStatus ApplicationReviewStatusEnum @default(pending) @map("review_status") - sendMailToMailingAddress Boolean? @map("send_mail_to_mailing_address") - status ApplicationStatusEnum @default(submitted) - applicationDeclineReason ApplicationDeclineReasonEnum? @map("application_decline_reason") - applicationDeclineReasonAdditionalDetails String? @map("application_decline_reason_additional_details") - submissionDate DateTime? @map("submission_date") @db.Timestamptz(6) - submissionType ApplicationSubmissionTypeEnum @map("submission_type") - userId String? @map("user_id") @db.Uuid + markedAsDuplicate Boolean @default(false) @map("marked_as_duplicate") + preferences Json + programs Json? + reviewStatus ApplicationReviewStatusEnum @default(pending) @map("review_status") + sendMailToMailingAddress Boolean? @map("send_mail_to_mailing_address") + status ApplicationStatusEnum @default(submitted) + applicationDeclineReason ApplicationDeclineReasonEnum? @map("application_decline_reason") + applicationDeclineReasonAdditionalDetails String? @map("application_decline_reason_additional_details") + submissionDate DateTime? @map("submission_date") @db.Timestamptz(6) + submissionType ApplicationSubmissionTypeEnum @map("submission_type") + userId String? @map("user_id") @db.Uuid // Used to denote when an application was transferred from another jurisdiction - wasCreatedExternally Boolean @default(false) @map("was_created_externally") - wasPIICleared Boolean? @default(false) @map("was_pii_cleared") + wasCreatedExternally Boolean @default(false) @map("was_created_externally") + wasPIICleared Boolean? @default(false) @map("was_pii_cleared") accessibility AccessibilitySnapshot? @relation(fields: [accessibilitySnapshotId], references: [id], onDelete: NoAction, onUpdate: NoAction) alternateContact AlternateContactSnapshot? @relation(fields: [alternateContactSnapshotId], references: [id], onDelete: NoAction, onUpdate: NoAction) @@ -784,36 +784,36 @@ model Jurisdictions { createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) - allowSingleUseCodeLogin Boolean @default(false) @map("allow_single_use_code_login") - raceEthnicityConfiguration Json? @map("race_ethnicity_configuration") - duplicateListingPermissions UserRoleEnum[] @map("duplicate_listing_permissions") - emailFromAddress String? @map("email_from_address") - enableGeocodingPreferences Boolean @default(false) @map("enable_geocoding_preferences") - enableGeocodingRadiusMethod Boolean @default(false) @map("enable_geocoding_radius_method") - enablePartnerDemographics Boolean @default(false) @map("enable_partner_demographics") - enablePartnerSettings Boolean @default(false) @map("enable_partner_settings") - languages LanguagesEnum[] @default([en]) - listingApprovalPermissions UserRoleEnum[] @map("listing_approval_permission") - listingFeaturesConfiguration Json? @map("listing_features_configuration") - minimumListingPublishImagesRequired Int? @default(1) @map("minimum_listing_publish_images_required") - name String @unique() - notificationsSignUpUrl String? @map("notifications_sign_up_url") - partnerTerms String? @map("partner_terms") - publicUrl String @default("") @map("public_url") - regions String[] @default([]) @map("regions") - referralSummaryDefault String? @map("referral_summary_default") - rentalAssistanceDefault String @map("rental_assistance_default") - requiredListingFields String[] @default([]) @map("required_listing_fields") - subJurisdictions Jurisdictions[] @relation("SubJurisdictions") - parentJurisdictions Jurisdictions[] @relation("SubJurisdictions") - visibleAccessibilityPriorityTypes UnitAccessibilityPriorityTypeEnum[] @default([mobility, hearing, vision, hearingAndVision, mobilityAndHearing, mobilityAndVision, mobilityHearingAndVision]) @map("visible_accessibility_priority_types") - visibleNeighborhoodAmenities NeighborhoodAmenitiesEnum[] @default([groceryStores, publicTransportation, schools, parksAndCommunityCenters, pharmacies, healthCareResources]) @map("visible_neighborhood_amenities") - visibleSpokenLanguages SpokenLanguageEnum[] @default([chineseCantonese, chineseMandarin, english, filipino, korean, russian, spanish, vietnamese, farsi, afghani, notListed]) @map("visible_spoken_languages") + allowSingleUseCodeLogin Boolean @default(false) @map("allow_single_use_code_login") + raceEthnicityConfiguration Json? @map("race_ethnicity_configuration") + duplicateListingPermissions UserRoleEnum[] @map("duplicate_listing_permissions") + emailFromAddress String? @map("email_from_address") + enableGeocodingPreferences Boolean @default(false) @map("enable_geocoding_preferences") + enableGeocodingRadiusMethod Boolean @default(false) @map("enable_geocoding_radius_method") + enablePartnerDemographics Boolean @default(false) @map("enable_partner_demographics") + enablePartnerSettings Boolean @default(false) @map("enable_partner_settings") + languages LanguagesEnum[] @default([en]) + listingApprovalPermissions UserRoleEnum[] @map("listing_approval_permission") + listingFeaturesConfiguration Json? @map("listing_features_configuration") + minimumListingPublishImagesRequired Int? @default(1) @map("minimum_listing_publish_images_required") + name String @unique() + notificationsSignUpUrl String? @map("notifications_sign_up_url") + partnerTerms String? @map("partner_terms") + publicUrl String @default("") @map("public_url") + regions String[] @default([]) @map("regions") + referralSummaryDefault String? @map("referral_summary_default") + rentalAssistanceDefault String @map("rental_assistance_default") + requiredListingFields String[] @default([]) @map("required_listing_fields") + subJurisdictions Jurisdictions[] @relation("SubJurisdictions") + parentJurisdictions Jurisdictions[] @relation("SubJurisdictions") + visibleAccessibilityPriorityTypes UnitAccessibilityPriorityTypeEnum[] @default([mobility, hearing, vision, hearingAndVision, mobilityAndHearing, mobilityAndVision, mobilityHearingAndVision]) @map("visible_accessibility_priority_types") + visibleNeighborhoodAmenities NeighborhoodAmenitiesEnum[] @default([groceryStores, publicTransportation, schools, parksAndCommunityCenters, pharmacies, healthCareResources]) @map("visible_neighborhood_amenities") + visibleSpokenLanguages SpokenLanguageEnum[] @default([chineseCantonese, chineseMandarin, english, filipino, korean, russian, spanish, vietnamese, farsi, afghani, notListed]) @map("visible_spoken_languages") visibleApplicationAccessibilityFeatures ApplicationAccessibilityFeatureEnum[] @default([mobility, hearing, vision]) @map("visible_application_accessibility_features") - visibleHouseholdMemberRelationships HouseholdMemberRelationshipEnum[] @default([spouse, registeredDomesticPartner, parent, child, sibling, cousin, aunt, uncle, nephew, niece, grandparent, greatGrandparent, inLaw, friend, other, aideOrAttendant]) @map("visible_household_member_relationships") - whatToExpect String @default("Applicants will be contacted by the property agent in rank order until vacancies are filled. All of the information that you have provided will be verified and your eligibility confirmed. Your application will be removed from the waitlist if you have made any fraudulent statements. If we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized. Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.") @map("what_to_expect") - whatToExpectAdditionalText String @default("") @map("what_to_expect_additional_text") - whatToExpectUnderConstruction String @default("") @map("what_to_expect_under_construction") + visibleHouseholdMemberRelationships HouseholdMemberRelationshipEnum[] @default([spouse, registeredDomesticPartner, parent, child, sibling, cousin, aunt, uncle, nephew, niece, grandparent, greatGrandparent, inLaw, friend, other, aideOrAttendant]) @map("visible_household_member_relationships") + whatToExpect String @default("Applicants will be contacted by the property agent in rank order until vacancies are filled. All of the information that you have provided will be verified and your eligibility confirmed. Your application will be removed from the waitlist if you have made any fraudulent statements. If we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized. Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.") @map("what_to_expect") + whatToExpectAdditionalText String @default("") @map("what_to_expect_additional_text") + whatToExpectUnderConstruction String @default("") @map("what_to_expect_under_construction") agency Agency[] amiCharts AmiChart[] From e63f3530c86b2d90e4f43d3b273dfcbdf2250e73 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 16:19:01 +0200 Subject: [PATCH 11/54] chore: add background job module to main App module --- api/src/modules/app.module.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/src/modules/app.module.ts b/api/src/modules/app.module.ts index da674acda28..2413753a9f4 100644 --- a/api/src/modules/app.module.ts +++ b/api/src/modules/app.module.ts @@ -26,6 +26,7 @@ import { ThrottlerModule } from '@nestjs/throttler'; import { UnitRentTypeModule } from './unit-rent-type.module'; import { UnitTypeModule } from './unit-type.module'; import { UserModule } from './user.module'; +import { BackgroundJobsModule } from './background-jobs.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { UserModule } from './user.module'; UserModule, PrismaModule, AuthModule, + BackgroundJobsModule, ApplicationFlaggedSetModule, MapLayerModule, ScriptRunnerModule, @@ -82,6 +84,7 @@ import { UserModule } from './user.module'; UserModule, PrismaModule, AuthModule, + BackgroundJobsModule, ApplicationFlaggedSetModule, MapLayerModule, ScriptRunnerModule, From 042f26dcbe46267f1aa6861c25a64cc0f6903af2 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 16:26:51 +0200 Subject: [PATCH 12/54] fix: add missing await --- api/src/services/background-jobs.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index b8fa62151c0..079a44b8b16 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -107,7 +107,7 @@ export class BackgroundJobsService { * @returns True if any active job exists (false otherwise) */ async findActiveJob(): Promise { - const activeJob = this.prismaService.backgroundJob.findFirst({ + const activeJob = await this.prismaService.backgroundJob.findFirst({ select: {}, where: { status: BackgroundJobStatusEnum.processing, From 11d74c50814f4ed16c821f30df7692955ca140b9 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 17:03:01 +0200 Subject: [PATCH 13/54] fix: fix final bugs in the job service implementation --- api/src/services/background-jobs.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index 079a44b8b16..c25a780b674 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -12,7 +12,7 @@ import { S3Service } from './s3.service'; import { User } from 'src/dtos/users/user.dto'; import { BackgroundJobCreate } from 'src/dtos/background-jobs/background-job-create.dto'; import { PermissionService } from './permission.service'; -import { permissionActions } from 'src/enums/permissions/permission-actions-enum'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; @Injectable() export class BackgroundJobsService { @@ -37,7 +37,7 @@ export class BackgroundJobsService { await this.permissionService.canOrThrow( requestingUser, - 'job', + 'jobs', permissionActions.create, ); @@ -74,7 +74,7 @@ export class BackgroundJobsService { */ async getById(jobId: string): Promise { try { - const jobData = this.prismaService.backgroundJob.findFirstOrThrow({ + const jobData = await this.prismaService.backgroundJob.findFirstOrThrow({ where: { id: jobId, }, From e1236ded2614983c7105cff0e6301d851e42746e Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 17:05:12 +0200 Subject: [PATCH 14/54] chore: add background jobs service integration tests --- .../services/background-jobs.service.spec.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 api/test/unit/services/background-jobs.service.spec.ts diff --git a/api/test/unit/services/background-jobs.service.spec.ts b/api/test/unit/services/background-jobs.service.spec.ts new file mode 100644 index 00000000000..b5c127d871c --- /dev/null +++ b/api/test/unit/services/background-jobs.service.spec.ts @@ -0,0 +1,196 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + ConflictException, + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { BackgroundJobStatusEnum } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { BackgroundJobsService } from '../../../src/services/background-jobs.service'; +import { PrismaService } from '../../../src/services/prisma.service'; +import { S3Service } from '../../../src/services/s3.service'; +import { PermissionService } from '../../../src/services/permission.service'; +import { BackgroundJob } from '../../../src/dtos/background-jobs/background-job.dto'; +import { UserRoleEnum } from '../../../src/enums/permissions/user-role-enum'; + +const listingId = randomUUID(); +const jobId = randomUUID(); +const userId = randomUUID(); + +const requestingUser = { id: userId } as any; + +const createDto = { + listingId, + inputS3Key: 'uploads/test-file.csv', +}; + +const dbJobRecord = { + id: jobId, + listingId, + requestedByUserId: userId, + inputS3Key: 'uploads/test-file.csv', + status: BackgroundJobStatusEnum.processing, + totalRecords: null, + errorMessage: null, + errorRow: null, + completedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('Background Jobs Service Tests', () => { + let service: BackgroundJobsService; + let prisma: PrismaService; + let permissionService: PermissionService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BackgroundJobsService, + PrismaService, + { provide: S3Service, useValue: {} }, + { + provide: PermissionService, + useValue: { canOrThrow: jest.fn().mockResolvedValue(undefined) }, + }, + ], + }).compile(); + + service = module.get(BackgroundJobsService); + prisma = module.get(PrismaService); + permissionService = module.get(PermissionService); + }); + + describe('create', () => { + beforeEach(() => { + permissionService.canOrThrow = jest.fn().mockResolvedValue(undefined); + }); + + it('should create and return a BackgroundJob when no active job exists for the listing', async () => { + prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); + prisma.backgroundJob.create = jest.fn().mockResolvedValue(dbJobRecord); + + const result = await service.create(createDto, { + ...requestingUser, + userRoles: [UserRoleEnum.user], + }); + + expect(result).toBeInstanceOf(BackgroundJob); + expect(result.listingId).toBe(listingId); + expect(result.status).toBe(BackgroundJobStatusEnum.processing); + expect(prisma.backgroundJob.create).toHaveBeenCalledWith({ + data: { + listingId, + requestedByUserId: userId, + inputS3Key: createDto.inputS3Key, + status: BackgroundJobStatusEnum.processing, + }, + }); + }); + + it('should throw ForbiddenException when the user lacks permission to create a job', async () => { + permissionService.canOrThrow = jest + .fn() + .mockRejectedValue(new ForbiddenException()); + prisma.backgroundJob.findFirst = jest.fn(); + + await expect(service.create(createDto, requestingUser)).rejects.toThrow( + ForbiddenException, + ); + expect(prisma.backgroundJob.findFirst).not.toHaveBeenCalled(); + }); + + it('should throw ConflictException when an active job already exists for the listing', async () => { + prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(dbJobRecord); + + await expect(service.create(createDto, requestingUser)).rejects.toThrow( + new ConflictException( + `Listing with ID: ${listingId} has a currently running job assigned`, + ), + ); + }); + + it('should throw InternalServerErrorException when the DB create returns a falsy value', async () => { + prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); + prisma.backgroundJob.create = jest.fn().mockResolvedValue(null); + + await expect(service.create(createDto, requestingUser)).rejects.toThrow( + InternalServerErrorException, + ); + }); + }); + + describe('getById', () => { + it('should return a BackgroundJob when the job exists', async () => { + prisma.backgroundJob.findFirstOrThrow = jest + .fn() + .mockResolvedValue(dbJobRecord); + + const result = await service.getById(jobId); + + expect(result).toBeInstanceOf(BackgroundJob); + expect(result.listingId).toBe(listingId); + expect(result.status).toBe(BackgroundJobStatusEnum.processing); + expect(prisma.backgroundJob.findFirstOrThrow).toHaveBeenCalledWith({ + where: { id: jobId }, + }); + }); + + it('should throw NotFoundException when the job does not exist', async () => { + prisma.backgroundJob.findFirstOrThrow = jest + .fn() + .mockRejectedValue(new Error('Not found')); + + await expect(service.getById('non-existent-id')).rejects.toThrow( + new NotFoundException(`Job with id: non-existent-id was not found`), + ); + }); + }); + + describe('findActiveForListing', () => { + it('should return a BackgroundJob when an active processing job exists', async () => { + prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(dbJobRecord); + const result = await service.findActiveForListing(listingId); + + expect(result).toBeInstanceOf(BackgroundJob); + expect(result?.listingId).toBe(listingId); + expect(result?.status).toBe(BackgroundJobStatusEnum.processing); + expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({ + where: { + listingId, + status: BackgroundJobStatusEnum.processing, + }, + }); + }); + + it('should return null when no active processing job exists for the listing', async () => { + prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); + const result = await service.findActiveForListing(listingId); + + expect(result).toBeNull(); + }); + }); + + describe('findActiveJob', () => { + it('should return true when a processing job exists', async () => { + prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(dbJobRecord); + const result = await service.findActiveJob(); + + expect(result).toBe(true); + expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({ + select: {}, + where: { + status: BackgroundJobStatusEnum.processing, + }, + }); + }); + + it('should return false when no processing job exists', async () => { + prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); + const result = await service.findActiveJob(); + + expect(result).toBe(false); + }); + }); +}); From e7f6f1179b55247eb1a1684a5607a21075708867 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 17:45:04 +0200 Subject: [PATCH 15/54] fix: fix import paths --- api/src/dtos/background-jobs/background-job-create.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/dtos/background-jobs/background-job-create.dto.ts b/api/src/dtos/background-jobs/background-job-create.dto.ts index 6a0da873b0a..2349f248639 100644 --- a/api/src/dtos/background-jobs/background-job-create.dto.ts +++ b/api/src/dtos/background-jobs/background-job-create.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; import { IsDefined, IsString, IsUUID } from 'class-validator'; -import { ValidationsGroupsEnum } from 'src/enums/shared/validation-groups-enum'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; export class BackgroundJobCreate { @Expose() From a25bfc87947701a945d702cad1d3f60ad87f301a Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 17:46:21 +0200 Subject: [PATCH 16/54] fix: fix endpoint param name typo --- api/src/controllers/background-jobs.controller.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index e3fd519aa79..b8d713c859b 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -62,7 +62,9 @@ export class BackgroundJobsController { }) @UseGuards(ApiKeyGuard) @ApiOkResponse({ type: BackgroundJob }) - public async getJobById(@Param('id') jobId: string): Promise { + public async getJobById( + @Param('jobId') jobId: string, + ): Promise { return await this.backgroundJobsService.getById(jobId); } } From a8dc08272e96b7b3dfa088cf40e82a99ef3328b5 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 19:32:21 +0200 Subject: [PATCH 17/54] chore: add background jobs controller e2e tests --- .../integration/background-jobs.e2e-spec.ts | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 api/test/integration/background-jobs.e2e-spec.ts diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts new file mode 100644 index 00000000000..428c0c04c38 --- /dev/null +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -0,0 +1,281 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { BackgroundJobStatusEnum } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import { AppModule } from '../../src/modules/app.module'; +import { PrismaService } from '../../src/services/prisma.service'; +import { userFactory } from '../../prisma/seed-helpers/user-factory'; +import { listingFactory } from '../../prisma/seed-helpers/listing-factory'; +import { jurisdictionFactory } from '../../prisma/seed-helpers/jurisdiction-factory'; + +describe('Background Jobs Controller Tests', () => { + let app: INestApplication; + let prisma: PrismaService; + let cookies = ''; + let listingId: string; + let jurisdictionId: string; + let storedUserId: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + prisma = moduleFixture.get(PrismaService); + app.use(cookieParser()); + await app.init(); + + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + mfaEnabled: false, + confirmedAt: new Date(), + }), + }); + storedUserId = storedUser.id; + + const resLogIn = await request(app.getHttpServer()) + .post('/auth/login') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ email: storedUser.email, password: 'Abcdef12345!' }) + .expect(201); + + cookies = resLogIn.headers['set-cookie']; + + const jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + + const listingData = await listingFactory(jurisdiction.id, prisma); + const listing = await prisma.listings.create({ data: listingData }); + listingId = listing.id; + jurisdictionId = jurisdiction.id; + }); + + afterAll(async () => { + await prisma.$disconnect(); + await app.close(); + }); + + describe('POST /jobs', () => { + afterEach(async () => { + await prisma.backgroundJob.deleteMany({ where: { listingId } }); + }); + + it('should create and return a background job with processing status', async () => { + const res = await request(app.getHttpServer()) + .post('/jobs') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .send({ listingId, inputS3Key: 'test-s3-key' }) + .expect(201); + + expect(res.body).toMatchObject({ + listingId, + requestedByUserId: storedUserId, + status: BackgroundJobStatusEnum.processing, + inputS3Key: 'test-s3-key', + }); + expect(res.body.id).toBeDefined(); + }); + + it('should return 409 when an active job already exists for the listing', async () => { + await prisma.backgroundJob.create({ + data: { + listingId, + requestedByUserId: storedUserId, + inputS3Key: 'uploads/existing.csv', + status: BackgroundJobStatusEnum.processing, + }, + }); + + const res = await request(app.getHttpServer()) + .post('/jobs') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .send({ listingId, inputS3Key: 'test-s3-key' }) + .expect(409); + + expect(res.body.message).toBe( + `Listing with ID: ${listingId} has a currently running job assigned`, + ); + }); + + it('should return 401 when passkey header is missing', async () => { + await request(app.getHttpServer()) + .post('/jobs') + .set('Cookie', cookies) + .send({ listingId, inputS3Key: 'test-s3-key' }) + .expect(401); + }); + + it('should return 401 when passkey header is incorrect', async () => { + await request(app.getHttpServer()) + .post('/jobs') + .set({ passkey: 'wrong-key' }) + .set('Cookie', cookies) + .send({ listingId, inputS3Key: 'test-s3-key' }) + .expect(401); + }); + + it('should return 400 when required body fields are missing', async () => { + await request(app.getHttpServer()) + .post('/jobs') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .send({}) + .expect(400); + }); + + it('should return 400 when listingId is not a valid UUID', async () => { + await request(app.getHttpServer()) + .post('/jobs') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .send({ listingId: 'not-a-uuid', inputS3Key: 'test-s3-key' }) + .expect(400); + }); + }); + + describe('GET /jobs', () => { + let seededJobId: string; + + beforeAll(async () => { + const seededJob = await prisma.backgroundJob.create({ + data: { + listingId, + requestedByUserId: storedUserId, + inputS3Key: 'test-s3-key', + status: BackgroundJobStatusEnum.processing, + }, + }); + seededJobId = seededJob.id; + }); + + afterAll(async () => { + await prisma.backgroundJob.deleteMany({ where: { listingId } }); + }); + + it('should return the active job for a listing', async () => { + const res = await request(app.getHttpServer()) + .get('/jobs') + .query({ listingId }) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body).toMatchObject({ + id: seededJobId, + listingId, + status: BackgroundJobStatusEnum.processing, + inputS3Key: 'test-s3-key', + }); + }); + + it('should return null when no active job exists for the listing', async () => { + const listingData = await listingFactory(jurisdictionId, prisma); + const newListing = await prisma.listings.create({ data: listingData }); + + prisma.backgroundJob.createMany({ + data: [ + { + listingId: newListing.id, + status: BackgroundJobStatusEnum.processing, + requestedByUserId: storedUserId, + inputS3Key: 'test-s3-key', + }, + { + listingId: newListing.id, + status: BackgroundJobStatusEnum.failed, + requestedByUserId: storedUserId, + inputS3Key: 'test-s3-key', + }, + ], + }); + + const res = await request(app.getHttpServer()) + .get('/jobs') + .query({ listingId: newListing.id }) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body).toBeNull(); + }); + + it('should return 401 when passkey header is missing', async () => { + await request(app.getHttpServer()) + .get('/jobs') + .query({ listingId }) + .set('Cookie', cookies) + .expect(401); + }); + + it('should return 401 when JWT cookie is missing', async () => { + await request(app.getHttpServer()) + .get('/jobs') + .query({ listingId }) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .expect(401); + }); + }); + + describe('GET /jobs/:jobId', () => { + let seededJobId: string; + + beforeAll(async () => { + const seededJob = await prisma.backgroundJob.create({ + data: { + listingId, + requestedByUserId: storedUserId, + inputS3Key: 'test-s3-key', + status: BackgroundJobStatusEnum.processing, + }, + }); + seededJobId = seededJob.id; + }); + + afterAll(async () => { + await prisma.backgroundJob.deleteMany({ where: { listingId } }); + }); + + it('should return a background job by ID', async () => { + const res = await request(app.getHttpServer()) + .get(`/jobs/${seededJobId}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body).toMatchObject({ + id: seededJobId, + listingId, + status: BackgroundJobStatusEnum.processing, + inputS3Key: 'test-s3-key', + }); + }); + + it('should return 404 when job does not exist', async () => { + const nonExistentId = randomUUID(); + + const res = await request(app.getHttpServer()) + .get(`/jobs/${nonExistentId}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(404); + + expect(res.body.message).toBe( + `Job with id: ${nonExistentId} was not found`, + ); + }); + + it('should return 401 when passkey header is missing', async () => { + await request(app.getHttpServer()) + .get(`/jobs/${seededJobId}`) + .set('Cookie', cookies) + .expect(401); + }); + }); +}); From 3928ed2a2fc88f84d7f9d45cee9407096d70b21b Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 19:59:48 +0200 Subject: [PATCH 18/54] chore: update table index to include the status column --- .../migrations/63_add_background_jobs_table/migration.sql | 2 +- api/prisma/schema.prisma | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/prisma/migrations/63_add_background_jobs_table/migration.sql b/api/prisma/migrations/63_add_background_jobs_table/migration.sql index 9e434f311c5..a23274cb6f2 100644 --- a/api/prisma/migrations/63_add_background_jobs_table/migration.sql +++ b/api/prisma/migrations/63_add_background_jobs_table/migration.sql @@ -19,4 +19,4 @@ CREATE TABLE "background_job" ( ); -- CreateIndex -CREATE INDEX "background_job_listing_id_idx" ON "background_job"("listing_id"); +CREATE INDEX "background_job_listing_id_status_idx" ON "background_job"("listing_id", "status"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index db28c320d5e..f749b228408 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -2251,7 +2251,7 @@ model BackgroundJob { updatedAt DateTime @updatedAt @map("updated_at") completedAt DateTime? @map("completed-at") - @@index([listingId]) + @@index([listingId, status]) @@map("background_job") } From dc2cd4e11da1d3190bbdfe4a42fc780574d11c47 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 20:00:02 +0200 Subject: [PATCH 19/54] fix: add missing null return type --- api/src/controllers/background-jobs.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index b8d713c859b..ff2205f2125 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -51,7 +51,7 @@ export class BackgroundJobsController { @ApiOkResponse({ type: BackgroundJob }) public async getListingActiveJob( @Query('listingId') listingId: string, - ): Promise { + ): Promise { return await this.backgroundJobsService.findActiveForListing(listingId); } From 2e5de29a938696c61eed24cb9163f3f0318b628a Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 20:00:14 +0200 Subject: [PATCH 20/54] chore: remove unnecessary guard --- api/src/controllers/background-jobs.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index ff2205f2125..599f48fc7e8 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -60,7 +60,6 @@ export class BackgroundJobsController { summary: 'Get a background job data by its ID', operationId: 'getBackgroundJob', }) - @UseGuards(ApiKeyGuard) @ApiOkResponse({ type: BackgroundJob }) public async getJobById( @Param('jobId') jobId: string, From 14d4aed4c0cf1117b3c0c90efcb6ed0cb0279b38 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 20:00:39 +0200 Subject: [PATCH 21/54] fix: remove unnecessary prisma result check --- api/src/services/background-jobs.service.ts | 5 ----- api/test/unit/services/background-jobs.service.spec.ts | 9 --------- 2 files changed, 14 deletions(-) diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index c25a780b674..616822a72a6 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -1,7 +1,6 @@ import { ConflictException, Injectable, - InternalServerErrorException, NotFoundException, } from '@nestjs/common'; import { PrismaService } from './prisma.service'; @@ -60,10 +59,6 @@ export class BackgroundJobsService { }, }); - if (!backgroundJob) { - throw new InternalServerErrorException('Failed to create new job record'); - } - return mapTo(BackgroundJob, backgroundJob); } diff --git a/api/test/unit/services/background-jobs.service.spec.ts b/api/test/unit/services/background-jobs.service.spec.ts index b5c127d871c..bf55889578c 100644 --- a/api/test/unit/services/background-jobs.service.spec.ts +++ b/api/test/unit/services/background-jobs.service.spec.ts @@ -110,15 +110,6 @@ describe('Background Jobs Service Tests', () => { ), ); }); - - it('should throw InternalServerErrorException when the DB create returns a falsy value', async () => { - prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); - prisma.backgroundJob.create = jest.fn().mockResolvedValue(null); - - await expect(service.create(createDto, requestingUser)).rejects.toThrow( - InternalServerErrorException, - ); - }); }); describe('getById', () => { From 0c225cb071678846a14746abcfa788bf3ed5d533 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 20:01:33 +0200 Subject: [PATCH 22/54] fix: fix e2e test --- api/test/integration/background-jobs.e2e-spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts index 428c0c04c38..96dc1d1056b 100644 --- a/api/test/integration/background-jobs.e2e-spec.ts +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -179,11 +179,11 @@ describe('Background Jobs Controller Tests', () => { const listingData = await listingFactory(jurisdictionId, prisma); const newListing = await prisma.listings.create({ data: listingData }); - prisma.backgroundJob.createMany({ + await prisma.backgroundJob.createMany({ data: [ { listingId: newListing.id, - status: BackgroundJobStatusEnum.processing, + status: BackgroundJobStatusEnum.completed, requestedByUserId: storedUserId, inputS3Key: 'test-s3-key', }, From f919f006cab7ca2724e56004cf623f6903dc0e15 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 20:02:48 +0200 Subject: [PATCH 23/54] chore: simplify the background jib fetch --- api/src/services/background-jobs.service.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index 616822a72a6..872907b6690 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -40,7 +40,16 @@ export class BackgroundJobsService { permissionActions.create, ); - const hasActiveListing = !!(await this.findActiveForListing(listingId)); + const hasActiveListing = + !!(await this.prismaService.backgroundJob.findFirst({ + select: { + id: true, + }, + where: { + listingId: listingId, + status: BackgroundJobStatusEnum.processing, + }, + })); if (hasActiveListing) { throw new ConflictException( From 225ca621472d819890b331a2d4221b41a696989c Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 20:13:32 +0200 Subject: [PATCH 24/54] fix: remove unnecessary import --- api/test/unit/services/background-jobs.service.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/api/test/unit/services/background-jobs.service.spec.ts b/api/test/unit/services/background-jobs.service.spec.ts index bf55889578c..0717192c9d0 100644 --- a/api/test/unit/services/background-jobs.service.spec.ts +++ b/api/test/unit/services/background-jobs.service.spec.ts @@ -2,7 +2,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConflictException, ForbiddenException, - InternalServerErrorException, NotFoundException, } from '@nestjs/common'; import { BackgroundJobStatusEnum } from '@prisma/client'; From 4ccef64287576631d8afb330bccc8e89a6144f43 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 20:14:35 +0200 Subject: [PATCH 25/54] fix: add missing DTO validation pipes --- api/src/controllers/background-jobs.controller.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index 599f48fc7e8..5c3af34bb65 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -7,8 +7,15 @@ import { Query, Request, UseGuards, + UsePipes, + ValidationPipe, } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { Request as ExpressRequest } from 'express'; import { ApiKeyGuard } from '../guards/api-key.guard'; import { BackgroundJobsService } from '../services/background-jobs.service'; @@ -18,9 +25,11 @@ import { User } from '../dtos/users/user.dto'; import { BackgroundJob } from '../dtos/background-jobs/background-job.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; @Controller('jobs') @ApiTags('jobs') +@ApiExtraModels(BackgroundJob, BackgroundJobCreate) @PermissionTypeDecorator('jobs') @UseGuards(ApiKeyGuard, JwtAuthGuard) export class BackgroundJobsController { @@ -31,6 +40,7 @@ export class BackgroundJobsController { summary: 'Creates a new background job record in the database', operationId: 'createBackgroundJob', }) + @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @UseGuards(ApiKeyGuard) @ApiOkResponse({ type: BackgroundJob }) public async createBackgroundJob( From 56905d46d14aedb9e220ae45908b837e4276f1a9 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 20:40:27 +0200 Subject: [PATCH 26/54] fix: update failing test --- api/test/integration/background-jobs.e2e-spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts index 96dc1d1056b..b43a3da61be 100644 --- a/api/test/integration/background-jobs.e2e-spec.ts +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -203,7 +203,7 @@ describe('Background Jobs Controller Tests', () => { .set('Cookie', cookies) .expect(200); - expect(res.body).toBeNull(); + expect(res.body).toBeEmpty(); }); it('should return 401 when passkey header is missing', async () => { From 93191a5e8598aa708174946fad5fb8b2f55db593 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Wed, 24 Jun 2026 20:50:27 +0200 Subject: [PATCH 27/54] chore: update backend swagger types --- shared-helpers/src/types/backend-swagger.ts | 116 +++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 1b49402dfd9..8f97c778af7 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -2537,6 +2537,69 @@ export class AuthService { } } +export class JobsService { + /** + * Creates a new background job record in the database + */ + createBackgroundJob( + params: { + /** requestBody */ + body?: BackgroundJobCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/jobs" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } + /** + * Get an active background job for a listing + */ + findActiveJobForListing( + params: { + /** */ + listingId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/jobs" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { listingId: params["listingId"] } + + axios(configs, resolve, reject) + }) + } + /** + * Get a background job data by its ID + */ + getBackgroundJob( + params: { + /** */ + jobId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/jobs/{jobId}" + url = url.replace("{jobId}", params["jobId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + axios(configs, resolve, reject) + }) + } +} + export class MapLayersService { /** * List map layers @@ -10008,6 +10071,51 @@ export interface Confirm { password?: string } +/** BackgroundJob */ +export interface BackgroundJob { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + listingId: string + + /** */ + requestedByUserId: string + + /** */ + status: BackgroundJobStatusEnum + + /** */ + totalRecords?: number + + /** */ + inputS3Key: string + + /** */ + errorMessage?: string + + /** */ + errorRow?: number + + /** */ + completedAt?: Date +} + +/** BackgroundJobCreate */ +export interface BackgroundJobCreate { + /** */ + listingId: string + + /** */ + inputS3Key: string +} + /** MapLayer */ export interface MapLayer { /** */ @@ -10752,7 +10860,6 @@ export enum FeatureFlagEnum { "enableFilterByBathroom" = "enableFilterByBathroom", "enableFullTimeStudentQuestion" = "enableFullTimeStudentQuestion", "enableGenderQuestion" = "enableGenderQuestion", - "enableSexualOrientationQuestion" = "enableSexualOrientationQuestion", "enableGeocodingPreferences" = "enableGeocodingPreferences", "enableGeocodingRadiusMethod" = "enableGeocodingRadiusMethod", "enableHomeType" = "enableHomeType", @@ -10788,6 +10895,7 @@ export enum FeatureFlagEnum { "enableRegions" = "enableRegions", "enableResources" = "enableResources", "enableSection8Question" = "enableSection8Question", + "enableSexualOrientationQuestion" = "enableSexualOrientationQuestion", "enableSingleUseCode" = "enableSingleUseCode", "enableSmokingPolicyRadio" = "enableSmokingPolicyRadio", "enableSpokenLanguage" = "enableSpokenLanguage", @@ -10840,6 +10948,12 @@ export enum MfaType { "sms" = "sms", "email" = "email", } + +export enum BackgroundJobStatusEnum { + "processing" = "processing", + "completed" = "completed", + "failed" = "failed", +} export enum EnumPropertyFilterParamsComparison { "=" = "=", "<>" = "<>", From 9b016b6b3845cd97af587b71912dfeaa84f48924 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 6 Jul 2026 10:20:19 +0200 Subject: [PATCH 28/54] fix: update typo in schema mapped name --- .../migrations/63_add_background_jobs_table/migration.sql | 2 +- api/prisma/schema.prisma | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/prisma/migrations/63_add_background_jobs_table/migration.sql b/api/prisma/migrations/63_add_background_jobs_table/migration.sql index a23274cb6f2..6c58ce7ecd4 100644 --- a/api/prisma/migrations/63_add_background_jobs_table/migration.sql +++ b/api/prisma/migrations/63_add_background_jobs_table/migration.sql @@ -13,7 +13,7 @@ CREATE TABLE "background_job" ( "error_row" INTEGER, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL, - "completed-at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), CONSTRAINT "background_job_pkey" PRIMARY KEY ("id") ); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 22851a1cad5..76b4e4583af 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -2251,7 +2251,7 @@ model BackgroundJob { errorRow Int? @map("error_row") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - completedAt DateTime? @map("completed-at") + completedAt DateTime? @map("completed_at") @@index([listingId, status]) @@map("background_job") From 25be74a137347b9be153829b9bd3ec7c16e8d961 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 6 Jul 2026 10:20:39 +0200 Subject: [PATCH 29/54] fix: add missing default UUIDv4 generation --- .../migrations/63_add_background_jobs_table/migration.sql | 2 +- api/prisma/schema.prisma | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/prisma/migrations/63_add_background_jobs_table/migration.sql b/api/prisma/migrations/63_add_background_jobs_table/migration.sql index 6c58ce7ecd4..592db39e077 100644 --- a/api/prisma/migrations/63_add_background_jobs_table/migration.sql +++ b/api/prisma/migrations/63_add_background_jobs_table/migration.sql @@ -3,7 +3,7 @@ CREATE TYPE "BackgroundJobStatusEnum" AS ENUM ('processing', 'completed', 'faile -- CreateTable CREATE TABLE "background_job" ( - "id" UUID NOT NULL, + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), "listing_id" UUID NOT NULL, "requested_by_user_id" UUID NOT NULL, "status" "BackgroundJobStatusEnum" NOT NULL, diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 76b4e4583af..6484e699634 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -2241,7 +2241,7 @@ model UserRoleSnapshot { } model BackgroundJob { - id String @id @default(uuid()) @db.Uuid + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid listingId String @map("listing_id") @db.Uuid requestedByUserId String @map("requested_by_user_id") @db.Uuid status BackgroundJobStatusEnum From 0045109654e54b48de4e3c786627e0a293d5f5d3 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 6 Jul 2026 10:22:57 +0200 Subject: [PATCH 30/54] fix: add permission service checks to all background job service class methods --- .../controllers/background-jobs.controller.ts | 12 +++++++-- api/src/services/background-jobs.service.ts | 27 ++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index 5c3af34bb65..734c0c4d0b0 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -61,8 +61,12 @@ export class BackgroundJobsController { @ApiOkResponse({ type: BackgroundJob }) public async getListingActiveJob( @Query('listingId') listingId: string, + @Request() req: ExpressRequest, ): Promise { - return await this.backgroundJobsService.findActiveForListing(listingId); + return await this.backgroundJobsService.findActiveForListing( + listingId, + mapTo(User, req['user']), + ); } @Get(':jobId') @@ -73,7 +77,11 @@ export class BackgroundJobsController { @ApiOkResponse({ type: BackgroundJob }) public async getJobById( @Param('jobId') jobId: string, + @Request() req: ExpressRequest, ): Promise { - return await this.backgroundJobsService.getById(jobId); + return await this.backgroundJobsService.getById( + jobId, + mapTo(User, req['user']), + ); } } diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index 872907b6690..be629e10b94 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -76,7 +76,13 @@ export class BackgroundJobsService { * @param jobId - Id of the job to return data on * @returns Details on the requested job */ - async getById(jobId: string): Promise { + async getById(jobId: string, requestingUser: User): Promise { + await this.permissionService.canOrThrow( + requestingUser, + 'jobs', + permissionActions.read, + ); + try { const jobData = await this.prismaService.backgroundJob.findFirstOrThrow({ where: { @@ -95,7 +101,16 @@ export class BackgroundJobsService { * @param listingId - Id of the listing for which the job should be retrieved * @returns Details on the currently processed job for the desired listing */ - async findActiveForListing(listingId: string): Promise { + async findActiveForListing( + listingId: string, + requestingUser: User, + ): Promise { + await this.permissionService.canOrThrow( + requestingUser, + 'jobs', + permissionActions.read, + ); + const activeJob = await this.prismaService.backgroundJob.findFirst({ where: { listingId: listingId, @@ -110,7 +125,13 @@ export class BackgroundJobsService { * Returns true if there is any job running (i.e. in status other than completed or failed) * @returns True if any active job exists (false otherwise) */ - async findActiveJob(): Promise { + async findActiveJob(requestingUser: User): Promise { + await this.permissionService.canOrThrow( + requestingUser, + 'jobs', + permissionActions.read, + ); + const activeJob = await this.prismaService.backgroundJob.findFirst({ select: {}, where: { From 5993ab7aad1cdd485d43bd4e2569915a5b3d2e9d Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 6 Jul 2026 10:23:21 +0200 Subject: [PATCH 31/54] fix: optimize active job serach prisma query by limiting columns number --- api/src/services/background-jobs.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index be629e10b94..da62833e7cf 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -133,7 +133,7 @@ export class BackgroundJobsService { ); const activeJob = await this.prismaService.backgroundJob.findFirst({ - select: {}, + select: { id: true }, where: { status: BackgroundJobStatusEnum.processing, }, From 457a6ec73a548fad44290bb31d2444cacca8f877 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 6 Jul 2026 10:23:57 +0200 Subject: [PATCH 32/54] fix: add validation pipes to query and param based id strings --- api/src/controllers/background-jobs.controller.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index 734c0c4d0b0..0ce77ad22c8 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, Param, + ParseUUIDPipe, Post, Query, Request, @@ -60,8 +61,8 @@ export class BackgroundJobsController { }) @ApiOkResponse({ type: BackgroundJob }) public async getListingActiveJob( - @Query('listingId') listingId: string, @Request() req: ExpressRequest, + @Query('listingId', new ParseUUIDPipe({ version: '4' })) listingId: string, ): Promise { return await this.backgroundJobsService.findActiveForListing( listingId, @@ -76,8 +77,8 @@ export class BackgroundJobsController { }) @ApiOkResponse({ type: BackgroundJob }) public async getJobById( - @Param('jobId') jobId: string, @Request() req: ExpressRequest, + @Param('jobId', new ParseUUIDPipe({ version: '4' })) jobId: string, ): Promise { return await this.backgroundJobsService.getById( jobId, From dfac35d997aa768b4ecfcac3450e0917d5124b77 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 6 Jul 2026 10:27:02 +0200 Subject: [PATCH 33/54] fix: update background jobs service integration tests --- .../services/background-jobs.service.spec.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/api/test/unit/services/background-jobs.service.spec.ts b/api/test/unit/services/background-jobs.service.spec.ts index 0717192c9d0..1751a173fe9 100644 --- a/api/test/unit/services/background-jobs.service.spec.ts +++ b/api/test/unit/services/background-jobs.service.spec.ts @@ -117,7 +117,7 @@ describe('Background Jobs Service Tests', () => { .fn() .mockResolvedValue(dbJobRecord); - const result = await service.getById(jobId); + const result = await service.getById(jobId, requestingUser); expect(result).toBeInstanceOf(BackgroundJob); expect(result.listingId).toBe(listingId); @@ -132,7 +132,9 @@ describe('Background Jobs Service Tests', () => { .fn() .mockRejectedValue(new Error('Not found')); - await expect(service.getById('non-existent-id')).rejects.toThrow( + await expect( + service.getById('non-existent-id', requestingUser), + ).rejects.toThrow( new NotFoundException(`Job with id: non-existent-id was not found`), ); }); @@ -141,7 +143,10 @@ describe('Background Jobs Service Tests', () => { describe('findActiveForListing', () => { it('should return a BackgroundJob when an active processing job exists', async () => { prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(dbJobRecord); - const result = await service.findActiveForListing(listingId); + const result = await service.findActiveForListing( + listingId, + requestingUser, + ); expect(result).toBeInstanceOf(BackgroundJob); expect(result?.listingId).toBe(listingId); @@ -156,7 +161,10 @@ describe('Background Jobs Service Tests', () => { it('should return null when no active processing job exists for the listing', async () => { prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); - const result = await service.findActiveForListing(listingId); + const result = await service.findActiveForListing( + listingId, + requestingUser, + ); expect(result).toBeNull(); }); @@ -165,11 +173,11 @@ describe('Background Jobs Service Tests', () => { describe('findActiveJob', () => { it('should return true when a processing job exists', async () => { prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(dbJobRecord); - const result = await service.findActiveJob(); + const result = await service.findActiveJob(requestingUser); expect(result).toBe(true); expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({ - select: {}, + select: { id: true }, where: { status: BackgroundJobStatusEnum.processing, }, @@ -178,7 +186,7 @@ describe('Background Jobs Service Tests', () => { it('should return false when no processing job exists', async () => { prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); - const result = await service.findActiveJob(); + const result = await service.findActiveJob(requestingUser); expect(result).toBe(false); }); From 6821cdec703d008b1dc266dc8ae3e20436e4df35 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 6 Jul 2026 10:32:40 +0200 Subject: [PATCH 34/54] fix: fix linting error --- api/src/services/background-jobs.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index da62833e7cf..f680529c146 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -131,7 +131,7 @@ export class BackgroundJobsService { 'jobs', permissionActions.read, ); - + const activeJob = await this.prismaService.backgroundJob.findFirst({ select: { id: true }, where: { From 596918dca04f269976d5f104fcdc75a81c092ffe Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 6 Jul 2026 10:43:09 +0200 Subject: [PATCH 35/54] fix: update the e2e tests --- api/test/integration/background-jobs.e2e-spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts index b43a3da61be..2950d563c4d 100644 --- a/api/test/integration/background-jobs.e2e-spec.ts +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -200,10 +200,9 @@ describe('Background Jobs Controller Tests', () => { .get('/jobs') .query({ listingId: newListing.id }) .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) - .expect(200); + .set('Cookie', cookies); - expect(res.body).toBeEmpty(); + expect(res.body).toBeNull(); }); it('should return 401 when passkey header is missing', async () => { From 091ec7d782df656da9606290ab004afd3ee3eadb Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 6 Jul 2026 10:46:37 +0200 Subject: [PATCH 36/54] Revert "fix: update the e2e tests" This reverts commit 596918dca04f269976d5f104fcdc75a81c092ffe. --- api/test/integration/background-jobs.e2e-spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts index 2950d563c4d..b43a3da61be 100644 --- a/api/test/integration/background-jobs.e2e-spec.ts +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -200,9 +200,10 @@ describe('Background Jobs Controller Tests', () => { .get('/jobs') .query({ listingId: newListing.id }) .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies); + .set('Cookie', cookies) + .expect(200); - expect(res.body).toBeNull(); + expect(res.body).toBeEmpty(); }); it('should return 401 when passkey header is missing', async () => { From 1a902bd6799d385bd224a3b529e15e9a9fb7b0eb Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 11:37:14 +0200 Subject: [PATCH 37/54] fix; update to relative paths --- api/src/services/background-jobs.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index f680529c146..72743e8bd50 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -8,8 +8,8 @@ import { BackgroundJobStatusEnum } from '@prisma/client'; import { mapTo } from '../utilities/mapTo'; import { BackgroundJob } from '../dtos/background-jobs/background-job.dto'; import { S3Service } from './s3.service'; -import { User } from 'src/dtos/users/user.dto'; -import { BackgroundJobCreate } from 'src/dtos/background-jobs/background-job-create.dto'; +import { User } from '../dtos/users/user.dto'; +import { BackgroundJobCreate } from '../dtos/background-jobs/background-job-create.dto'; import { PermissionService } from './permission.service'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; From dcfe4d884a43918561cf8e845de1076c8eab755b Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 11:40:24 +0200 Subject: [PATCH 38/54] fix: move over from prisma findOrThrow method --- api/src/services/background-jobs.service.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index 72743e8bd50..9fc5c1aa3b8 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -83,17 +83,17 @@ export class BackgroundJobsService { permissionActions.read, ); - try { - const jobData = await this.prismaService.backgroundJob.findFirstOrThrow({ - where: { - id: jobId, - }, - }); + const jobData = await this.prismaService.backgroundJob.findFirst({ + where: { + id: jobId, + }, + }); - return mapTo(BackgroundJob, jobData); - } catch { + if (!jobData) { throw new NotFoundException(`Job with id: ${jobId} was not found`); } + + return mapTo(BackgroundJob, jobData); } /** From 70993df9138b1e0934f6a6fc0efe04fa51590086 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 12:33:35 +0200 Subject: [PATCH 39/54] chore: add new endpoint for getting the background jobs status --- api/src/controllers/background-jobs.controller.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index 0ce77ad22c8..15250254e1e 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -85,4 +85,16 @@ export class BackgroundJobsController { mapTo(User, req['user']), ); } + + @Get('active') + @ApiOperation({ + summary: 'Get info if any jobs are currently running', + operationId: 'activeJobStatus', + }) + @ApiOkResponse({ type: Boolean }) + public async activeJobStatus( + @Request() req: ExpressRequest, + ): Promise { + return this.backgroundJobsService.findActiveJob(mapTo(User, req['user'])); + } } From 4d7fc5572510e61189e6ceb8343581d0425b9c30 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 12:35:57 +0200 Subject: [PATCH 40/54] fix: remove the passkey related tests from suite --- api/test/integration/background-jobs.e2e-spec.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts index b43a3da61be..b579e750d7a 100644 --- a/api/test/integration/background-jobs.e2e-spec.ts +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -206,14 +206,6 @@ describe('Background Jobs Controller Tests', () => { expect(res.body).toBeEmpty(); }); - it('should return 401 when passkey header is missing', async () => { - await request(app.getHttpServer()) - .get('/jobs') - .query({ listingId }) - .set('Cookie', cookies) - .expect(401); - }); - it('should return 401 when JWT cookie is missing', async () => { await request(app.getHttpServer()) .get('/jobs') @@ -270,12 +262,5 @@ describe('Background Jobs Controller Tests', () => { `Job with id: ${nonExistentId} was not found`, ); }); - - it('should return 401 when passkey header is missing', async () => { - await request(app.getHttpServer()) - .get(`/jobs/${seededJobId}`) - .set('Cookie', cookies) - .expect(401); - }); }); }); From 19f3a40040cfd9814d6273f372d917b694c18f19 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 12:36:38 +0200 Subject: [PATCH 41/54] chore: add new e2e tests for the new endpoint --- .../integration/background-jobs.e2e-spec.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts index b579e750d7a..25fe6547d22 100644 --- a/api/test/integration/background-jobs.e2e-spec.ts +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -263,4 +263,71 @@ describe('Background Jobs Controller Tests', () => { ); }); }); + + describe('GET /jobs/active', async () => { + let seededJobId: string; + + beforeAll(async () => { + const seededJob = await prisma.backgroundJob.create({ + data: { + listingId, + requestedByUserId: storedUserId, + inputS3Key: 'test-s3-key', + status: BackgroundJobStatusEnum.processing, + }, + }); + seededJobId = seededJob.id; + }); + + afterAll(async () => { + await prisma.backgroundJob.deleteMany({ where: { listingId } }); + }); + + it('should return error when user does not have permissions', async () => { + const nonAdminUser = await prisma.userAccounts.create({ + data: await userFactory({ mfaEnabled: false, confirmedAt: new Date() }), + }); + + const resLogIn = await request(app.getHttpServer()) + .post('/auth/login') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ email: nonAdminUser.email, password: 'Abcdef12345!' }) + .expect(201); + + const nonAdminCookies = resLogIn.headers['set-cookie']; + + await request(app.getHttpServer()) + .get('/jobs/active') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', nonAdminCookies) + .expect(403); + + await prisma.userAccounts.delete({ where: { id: nonAdminUser.id } }); + }); + + it('should return true when an active jobs exists in the database', async () => { + const res = await request(app.getHttpServer()) + .get('/jobs/active') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body).toBe(true); + }); + + it('should return false when no active jobs exists in the database', async () => { + await prisma.backgroundJob.update({ + where: { id: seededJobId }, + data: { status: BackgroundJobStatusEnum.completed }, + }); + + const res = await request(app.getHttpServer()) + .get('/jobs/active') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body).toBe(false); + }); + }); }); From 645ff53cc13aca52503108cdee1dc6a064129cb2 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 12:46:24 +0200 Subject: [PATCH 42/54] fix: remove unnecessary async in tests --- api/test/integration/background-jobs.e2e-spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts index 25fe6547d22..17f761c37ba 100644 --- a/api/test/integration/background-jobs.e2e-spec.ts +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -264,7 +264,7 @@ describe('Background Jobs Controller Tests', () => { }); }); - describe('GET /jobs/active', async () => { + describe('GET /jobs/active', () => { let seededJobId: string; beforeAll(async () => { From cf222e7ad89597d341ce241b418414662640e58c Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 15:45:03 +0200 Subject: [PATCH 43/54] fix: update failing tests --- api/test/unit/services/background-jobs.service.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/test/unit/services/background-jobs.service.spec.ts b/api/test/unit/services/background-jobs.service.spec.ts index 1751a173fe9..2b200cb68fd 100644 --- a/api/test/unit/services/background-jobs.service.spec.ts +++ b/api/test/unit/services/background-jobs.service.spec.ts @@ -122,15 +122,13 @@ describe('Background Jobs Service Tests', () => { expect(result).toBeInstanceOf(BackgroundJob); expect(result.listingId).toBe(listingId); expect(result.status).toBe(BackgroundJobStatusEnum.processing); - expect(prisma.backgroundJob.findFirstOrThrow).toHaveBeenCalledWith({ + expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({ where: { id: jobId }, }); }); it('should throw NotFoundException when the job does not exist', async () => { - prisma.backgroundJob.findFirstOrThrow = jest - .fn() - .mockRejectedValue(new Error('Not found')); + prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); await expect( service.getById('non-existent-id', requestingUser), From af0d6599228b531f747649c67f1d09316b481d1d Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 16:32:20 +0200 Subject: [PATCH 44/54] fix: generate client --- shared-helpers/src/types/backend-swagger.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index ec79f04917e..33a30275e45 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -2614,6 +2614,18 @@ export class JobsService { const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + axios(configs, resolve, reject) + }) + } + /** + * Get info if any jobs are currently running + */ + activeJobStatus(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/jobs/active" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + axios(configs, resolve, reject) }) } From f2d7a949e60cf3097ce5bce4f8c62cc180919385 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 17:05:25 +0200 Subject: [PATCH 45/54] fix: reorder controller endpoint handlers --- .../controllers/background-jobs.controller.ts | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index 15250254e1e..3b13653c1f7 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -53,21 +53,16 @@ export class BackgroundJobsController { mapTo(User, req['user']), ); } - - @Get() + @Get('active') @ApiOperation({ - summary: 'Get an active background job for a listing', - operationId: 'findActiveJobForListing', + summary: 'Get info if any jobs are currently running', + operationId: 'activeJobStatus', }) - @ApiOkResponse({ type: BackgroundJob }) - public async getListingActiveJob( + @ApiOkResponse({ type: Boolean }) + public async activeJobStatus( @Request() req: ExpressRequest, - @Query('listingId', new ParseUUIDPipe({ version: '4' })) listingId: string, - ): Promise { - return await this.backgroundJobsService.findActiveForListing( - listingId, - mapTo(User, req['user']), - ); + ): Promise { + return this.backgroundJobsService.findActiveJob(mapTo(User, req['user'])); } @Get(':jobId') @@ -86,15 +81,19 @@ export class BackgroundJobsController { ); } - @Get('active') + @Get() @ApiOperation({ - summary: 'Get info if any jobs are currently running', - operationId: 'activeJobStatus', + summary: 'Get an active background job for a listing', + operationId: 'findActiveJobForListing', }) - @ApiOkResponse({ type: Boolean }) - public async activeJobStatus( + @ApiOkResponse({ type: BackgroundJob }) + public async getListingActiveJob( @Request() req: ExpressRequest, - ): Promise { - return this.backgroundJobsService.findActiveJob(mapTo(User, req['user'])); + @Query('listingId', new ParseUUIDPipe({ version: '4' })) listingId: string, + ): Promise { + return await this.backgroundJobsService.findActiveForListing( + listingId, + mapTo(User, req['user']), + ); } } From 7841bcde1a575ae1a6678b103e87d53f947c109e Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 17:23:38 +0200 Subject: [PATCH 46/54] fix: update the endpoint response to a successDTO --- api/src/controllers/background-jobs.controller.ts | 5 +++-- api/src/services/background-jobs.service.ts | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index 3b13653c1f7..f5f477802d7 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -27,6 +27,7 @@ import { BackgroundJob } from '../dtos/background-jobs/background-job.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { SuccessDTO } from 'src/dtos/shared/success.dto'; @Controller('jobs') @ApiTags('jobs') @@ -58,10 +59,10 @@ export class BackgroundJobsController { summary: 'Get info if any jobs are currently running', operationId: 'activeJobStatus', }) - @ApiOkResponse({ type: Boolean }) + @ApiOkResponse({ type: SuccessDTO }) public async activeJobStatus( @Request() req: ExpressRequest, - ): Promise { + ): Promise { return this.backgroundJobsService.findActiveJob(mapTo(User, req['user'])); } diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index 9fc5c1aa3b8..6c5699d3ac9 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -12,6 +12,7 @@ import { User } from '../dtos/users/user.dto'; import { BackgroundJobCreate } from '../dtos/background-jobs/background-job-create.dto'; import { PermissionService } from './permission.service'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { SuccessDTO } from 'src/dtos/shared/success.dto'; @Injectable() export class BackgroundJobsService { @@ -125,7 +126,7 @@ export class BackgroundJobsService { * Returns true if there is any job running (i.e. in status other than completed or failed) * @returns True if any active job exists (false otherwise) */ - async findActiveJob(requestingUser: User): Promise { + async findActiveJob(requestingUser: User): Promise { await this.permissionService.canOrThrow( requestingUser, 'jobs', @@ -139,6 +140,8 @@ export class BackgroundJobsService { }, }); - return !!activeJob; + return { + success: !!activeJob, + }; } } From 65d783bb9bbeff5c3b5edd21de77d1c5a16888d8 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 17:23:46 +0200 Subject: [PATCH 47/54] fix: update e2e tests --- api/test/integration/background-jobs.e2e-spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts index 17f761c37ba..ddaaa21db24 100644 --- a/api/test/integration/background-jobs.e2e-spec.ts +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -312,7 +312,9 @@ describe('Background Jobs Controller Tests', () => { .set('Cookie', cookies) .expect(200); - expect(res.body).toBe(true); + expect(res.body).toBe({ + success: true, + }); }); it('should return false when no active jobs exists in the database', async () => { @@ -327,7 +329,9 @@ describe('Background Jobs Controller Tests', () => { .set('Cookie', cookies) .expect(200); - expect(res.body).toBe(false); + expect(res.body).toBe({ + success: false, + }); }); }); }); From c11183b8f8572ba3d79a8854d21556cea0e2ef6b Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 17:26:25 +0200 Subject: [PATCH 48/54] fix: update background jobs service tests --- api/test/unit/services/background-jobs.service.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/test/unit/services/background-jobs.service.spec.ts b/api/test/unit/services/background-jobs.service.spec.ts index 2b200cb68fd..b6e24ba2e12 100644 --- a/api/test/unit/services/background-jobs.service.spec.ts +++ b/api/test/unit/services/background-jobs.service.spec.ts @@ -173,7 +173,9 @@ describe('Background Jobs Service Tests', () => { prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(dbJobRecord); const result = await service.findActiveJob(requestingUser); - expect(result).toBe(true); + expect(result).toBe({ + success: true, + }); expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({ select: { id: true }, where: { @@ -186,7 +188,9 @@ describe('Background Jobs Service Tests', () => { prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); const result = await service.findActiveJob(requestingUser); - expect(result).toBe(false); + expect(result).toBe({ + success: false, + }); }); }); }); From ec37121205fab0c4edd57e8916746c6b45df22b8 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 17:30:47 +0200 Subject: [PATCH 49/54] fix: change global imports to relative --- api/src/controllers/background-jobs.controller.ts | 2 +- api/src/services/background-jobs.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index f5f477802d7..bef0186d822 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -27,7 +27,7 @@ import { BackgroundJob } from '../dtos/background-jobs/background-job.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; -import { SuccessDTO } from 'src/dtos/shared/success.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; @Controller('jobs') @ApiTags('jobs') diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index 6c5699d3ac9..48878bc6b78 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -12,7 +12,7 @@ import { User } from '../dtos/users/user.dto'; import { BackgroundJobCreate } from '../dtos/background-jobs/background-job-create.dto'; import { PermissionService } from './permission.service'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; -import { SuccessDTO } from 'src/dtos/shared/success.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; @Injectable() export class BackgroundJobsService { From 6cc5c3a5f046c1fbbb4a2db5c8fdc16e961fe5aa Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Mon, 13 Jul 2026 17:39:58 +0200 Subject: [PATCH 50/54] fix: update new response body test validation method --- api/test/integration/background-jobs.e2e-spec.ts | 4 ++-- api/test/unit/services/background-jobs.service.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts index ddaaa21db24..b80f4d9dee7 100644 --- a/api/test/integration/background-jobs.e2e-spec.ts +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -312,7 +312,7 @@ describe('Background Jobs Controller Tests', () => { .set('Cookie', cookies) .expect(200); - expect(res.body).toBe({ + expect(res.body).toEqual({ success: true, }); }); @@ -329,7 +329,7 @@ describe('Background Jobs Controller Tests', () => { .set('Cookie', cookies) .expect(200); - expect(res.body).toBe({ + expect(res.body).toEqual({ success: false, }); }); diff --git a/api/test/unit/services/background-jobs.service.spec.ts b/api/test/unit/services/background-jobs.service.spec.ts index b6e24ba2e12..cf9af014802 100644 --- a/api/test/unit/services/background-jobs.service.spec.ts +++ b/api/test/unit/services/background-jobs.service.spec.ts @@ -173,7 +173,7 @@ describe('Background Jobs Service Tests', () => { prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(dbJobRecord); const result = await service.findActiveJob(requestingUser); - expect(result).toBe({ + expect(result).toEqual({ success: true, }); expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({ @@ -188,7 +188,7 @@ describe('Background Jobs Service Tests', () => { prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); const result = await service.findActiveJob(requestingUser); - expect(result).toBe({ + expect(result).toEqual({ success: false, }); }); From 170fa69b1a436dae3200dab332a20109c74d2901 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Tue, 14 Jul 2026 14:30:52 +0200 Subject: [PATCH 51/54] chore: update the background jobs to have a reference to user accounts --- .../migrations/63_add_background_jobs_table/migration.sql | 3 +++ api/prisma/schema.prisma | 2 ++ 2 files changed, 5 insertions(+) diff --git a/api/prisma/migrations/63_add_background_jobs_table/migration.sql b/api/prisma/migrations/63_add_background_jobs_table/migration.sql index 592db39e077..3c7640d51c2 100644 --- a/api/prisma/migrations/63_add_background_jobs_table/migration.sql +++ b/api/prisma/migrations/63_add_background_jobs_table/migration.sql @@ -20,3 +20,6 @@ CREATE TABLE "background_job" ( -- CreateIndex CREATE INDEX "background_job_listing_id_status_idx" ON "background_job"("listing_id", "status"); + +-- AddForeignKey +ALTER TABLE "background_job" ADD CONSTRAINT "background_job_requested_by_user_id_fkey" FOREIGN KEY ("requested_by_user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index f0ad790e1d2..93829017cde 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -2090,6 +2090,7 @@ model UserAccounts { jurisdictions Jurisdictions[] lastUpdatedListingsByUser Listings[] @relation("last_updated_by_user") listings Listings[] + backgroundJobs BackgroundJob[] notificationPreferences UserNotificationPreferences? requestedChangesListings Listings[] @relation("requested_changes_user") userPreferences UserPreferences? @@ -2244,6 +2245,7 @@ model BackgroundJob { id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid listingId String @map("listing_id") @db.Uuid requestedByUserId String @map("requested_by_user_id") @db.Uuid + requestedBy UserAccounts @relation(fields: [requestedByUserId], references: [id], onDelete: NoAction, onUpdate: NoAction) status BackgroundJobStatusEnum totalRecords Int? @map("total_records") inputS3Key String @map("input_s3_key") From d58b5df599fa4608be503e4613d296ab9a51755c Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Tue, 14 Jul 2026 14:31:12 +0200 Subject: [PATCH 52/54] chore: update endpoint to return an array of active jobs for listing --- api/src/controllers/background-jobs.controller.ts | 4 ++-- api/src/services/background-jobs.service.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts index bef0186d822..3dc8c5472f7 100644 --- a/api/src/controllers/background-jobs.controller.ts +++ b/api/src/controllers/background-jobs.controller.ts @@ -87,11 +87,11 @@ export class BackgroundJobsController { summary: 'Get an active background job for a listing', operationId: 'findActiveJobForListing', }) - @ApiOkResponse({ type: BackgroundJob }) + @ApiOkResponse({ type: Array }) public async getListingActiveJob( @Request() req: ExpressRequest, @Query('listingId', new ParseUUIDPipe({ version: '4' })) listingId: string, - ): Promise { + ): Promise { return await this.backgroundJobsService.findActiveForListing( listingId, mapTo(User, req['user']), diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts index 48878bc6b78..ef644a20262 100644 --- a/api/src/services/background-jobs.service.ts +++ b/api/src/services/background-jobs.service.ts @@ -105,21 +105,21 @@ export class BackgroundJobsService { async findActiveForListing( listingId: string, requestingUser: User, - ): Promise { + ): Promise { await this.permissionService.canOrThrow( requestingUser, 'jobs', permissionActions.read, ); - const activeJob = await this.prismaService.backgroundJob.findFirst({ + const activeJobs = await this.prismaService.backgroundJob.findMany({ where: { listingId: listingId, status: BackgroundJobStatusEnum.processing, }, }); - return activeJob ? mapTo(BackgroundJob, activeJob) : null; + return mapTo(BackgroundJob, activeJobs); } /** From f648465ae7a5d226c468e1584c1aaa99ffdc4b08 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Tue, 14 Jul 2026 15:26:12 +0200 Subject: [PATCH 53/54] fix: fix failing service tests --- .../services/background-jobs.service.spec.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/api/test/unit/services/background-jobs.service.spec.ts b/api/test/unit/services/background-jobs.service.spec.ts index cf9af014802..c4b09f36a0b 100644 --- a/api/test/unit/services/background-jobs.service.spec.ts +++ b/api/test/unit/services/background-jobs.service.spec.ts @@ -139,17 +139,20 @@ describe('Background Jobs Service Tests', () => { }); describe('findActiveForListing', () => { - it('should return a BackgroundJob when an active processing job exists', async () => { - prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(dbJobRecord); + it('should return a BackgroundJob array when an active processing job exists', async () => { + prisma.backgroundJob.findMany = jest + .fn() + .mockResolvedValue([dbJobRecord]); const result = await service.findActiveForListing( listingId, requestingUser, ); - expect(result).toBeInstanceOf(BackgroundJob); - expect(result?.listingId).toBe(listingId); - expect(result?.status).toBe(BackgroundJobStatusEnum.processing); - expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({ + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(BackgroundJob); + expect(result[0]?.listingId).toBe(listingId); + expect(result[0]?.status).toBe(BackgroundJobStatusEnum.processing); + expect(prisma.backgroundJob.findMany).toHaveBeenCalledWith({ where: { listingId, status: BackgroundJobStatusEnum.processing, @@ -157,14 +160,14 @@ describe('Background Jobs Service Tests', () => { }); }); - it('should return null when no active processing job exists for the listing', async () => { - prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); + it('should return an empty array when no active processing job exists for the listing', async () => { + prisma.backgroundJob.findMany = jest.fn().mockResolvedValue([]); const result = await service.findActiveForListing( listingId, requestingUser, ); - expect(result).toBeNull(); + expect(result).toHaveLength(0); }); }); From e41398f6a668c8e6be0eb5c5c1b562d98d0c2546 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk Date: Tue, 14 Jul 2026 15:46:29 +0200 Subject: [PATCH 54/54] fix: update controller e2e tests --- api/test/integration/background-jobs.e2e-spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/api/test/integration/background-jobs.e2e-spec.ts b/api/test/integration/background-jobs.e2e-spec.ts index b80f4d9dee7..9d9b69e84dd 100644 --- a/api/test/integration/background-jobs.e2e-spec.ts +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -159,7 +159,7 @@ describe('Background Jobs Controller Tests', () => { await prisma.backgroundJob.deleteMany({ where: { listingId } }); }); - it('should return the active job for a listing', async () => { + it('should return an array with the active job for a listing', async () => { const res = await request(app.getHttpServer()) .get('/jobs') .query({ listingId }) @@ -167,7 +167,8 @@ describe('Background Jobs Controller Tests', () => { .set('Cookie', cookies) .expect(200); - expect(res.body).toMatchObject({ + expect(res.body).toHaveLength(1); + expect(res.body[0]).toMatchObject({ id: seededJobId, listingId, status: BackgroundJobStatusEnum.processing, @@ -175,7 +176,7 @@ describe('Background Jobs Controller Tests', () => { }); }); - it('should return null when no active job exists for the listing', async () => { + it('should return empty array when no active job exists for the listing', async () => { const listingData = await listingFactory(jurisdictionId, prisma); const newListing = await prisma.listings.create({ data: listingData }); @@ -203,7 +204,7 @@ describe('Background Jobs Controller Tests', () => { .set('Cookie', cookies) .expect(200); - expect(res.body).toBeEmpty(); + expect(res.body).toHaveLength(0); }); it('should return 401 when JWT cookie is missing', async () => {