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 0000000000..3c7640d51c --- /dev/null +++ b/api/prisma/migrations/63_add_background_jobs_table/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "BackgroundJobStatusEnum" AS ENUM ('processing', 'completed', 'failed'); + +-- CreateTable +CREATE TABLE "background_job" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "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_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 85e8ba983a..b24800c215 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -376,23 +376,23 @@ 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") @@ -404,29 +404,29 @@ model Applications { incomePeriod IncomePeriodEnum? @map("income_period") incomeVouchers String[] @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,21 +457,21 @@ 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") @@ -483,27 +483,27 @@ model ApplicationSnapshot { incomePeriod IncomePeriodEnum? @map("income_period") incomeVouchers String[] @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[] @@ -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? @@ -2240,6 +2241,24 @@ model UserRoleSnapshot { @@map("user_role_snapshot") } +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") + 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, status]) + @@map("background_job") +} + // --- View Section --- // view ApplicationFlaggedSetPossibilities { key String @@ -2620,3 +2639,9 @@ enum YesNoEnum { @@map("yes_no_enum") } + +enum BackgroundJobStatusEnum { + processing + completed + failed +} diff --git a/api/src/controllers/background-jobs.controller.ts b/api/src/controllers/background-jobs.controller.ts new file mode 100644 index 0000000000..3dc8c5472f --- /dev/null +++ b/api/src/controllers/background-jobs.controller.ts @@ -0,0 +1,100 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + Request, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +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'; +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'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { SuccessDTO } from '../dtos/shared/success.dto'; + +@Controller('jobs') +@ApiTags('jobs') +@ApiExtraModels(BackgroundJob, BackgroundJobCreate) +@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', + }) + @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) + @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('active') + @ApiOperation({ + summary: 'Get info if any jobs are currently running', + operationId: 'activeJobStatus', + }) + @ApiOkResponse({ type: SuccessDTO }) + public async activeJobStatus( + @Request() req: ExpressRequest, + ): Promise { + return this.backgroundJobsService.findActiveJob(mapTo(User, req['user'])); + } + + @Get(':jobId') + @ApiOperation({ + summary: 'Get a background job data by its ID', + operationId: 'getBackgroundJob', + }) + @ApiOkResponse({ type: BackgroundJob }) + public async getJobById( + @Request() req: ExpressRequest, + @Param('jobId', new ParseUUIDPipe({ version: '4' })) jobId: string, + ): Promise { + return await this.backgroundJobsService.getById( + jobId, + mapTo(User, req['user']), + ); + } + + @Get() + @ApiOperation({ + summary: 'Get an active background job for a listing', + operationId: 'findActiveJobForListing', + }) + @ApiOkResponse({ type: Array }) + public async getListingActiveJob( + @Request() req: ExpressRequest, + @Query('listingId', new ParseUUIDPipe({ version: '4' })) listingId: string, + ): Promise { + return await this.backgroundJobsService.findActiveForListing( + listingId, + mapTo(User, req['user']), + ); + } +} 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 0000000000..2349f24863 --- /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 '../../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 0000000000..44a74dca62 --- /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; +} diff --git a/api/src/modules/app.module.ts b/api/src/modules/app.module.ts index 2669d04e42..f63189a9fe 100644 --- a/api/src/modules/app.module.ts +++ b/api/src/modules/app.module.ts @@ -27,6 +27,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: [ @@ -42,6 +43,7 @@ import { UserModule } from './user.module'; UserModule, PrismaModule, AuthModule, + BackgroundJobsModule, ApplicationFlaggedSetModule, MapLayerModule, ScriptRunnerModule, @@ -84,6 +86,7 @@ import { UserModule } from './user.module'; UserModule, PrismaModule, AuthModule, + BackgroundJobsModule, ApplicationFlaggedSetModule, MapLayerModule, ScriptRunnerModule, diff --git a/api/src/modules/background-jobs.module.ts b/api/src/modules/background-jobs.module.ts new file mode 100644 index 0000000000..fac5f2aba1 --- /dev/null +++ b/api/src/modules/background-jobs.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +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, S3Module, PermissionModule], + providers: [BackgroundJobsService], + controllers: [BackgroundJobsController], + exports: [BackgroundJobsService], +}) +export class BackgroundJobsModule {} diff --git a/api/src/permission-configs/permission_policy.csv b/api/src/permission-configs/permission_policy.csv index 644df56e19..072180b4a8 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, .* diff --git a/api/src/services/background-jobs.service.ts b/api/src/services/background-jobs.service.ts new file mode 100644 index 0000000000..ef644a2026 --- /dev/null +++ b/api/src/services/background-jobs.service.ts @@ -0,0 +1,147 @@ +import { + ConflictException, + Injectable, + 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 '../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 '../dtos/shared/success.dto'; + +@Injectable() +export class BackgroundJobsService { + 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 dto - background job creation DTO + * @param requestingUser - Data on requesting user + * @returns The newly created background job + */ + async create( + dto: BackgroundJobCreate, + requestingUser: User, + ): Promise { + const { listingId, inputS3Key } = dto; + const requestedByUserId = requestingUser.id; + + await this.permissionService.canOrThrow( + requestingUser, + 'jobs', + permissionActions.create, + ); + + const hasActiveListing = + !!(await this.prismaService.backgroundJob.findFirst({ + select: { + id: true, + }, + where: { + listingId: listingId, + status: BackgroundJobStatusEnum.processing, + }, + })); + + 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, + }, + }); + + return mapTo(BackgroundJob, backgroundJob); + } + + /** + * 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 + */ + async getById(jobId: string, requestingUser: User): Promise { + await this.permissionService.canOrThrow( + requestingUser, + 'jobs', + permissionActions.read, + ); + + const jobData = await this.prismaService.backgroundJob.findFirst({ + where: { + id: jobId, + }, + }); + + if (!jobData) { + throw new NotFoundException(`Job with id: ${jobId} was not found`); + } + + return mapTo(BackgroundJob, jobData); + } + + /** + * 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 + */ + async findActiveForListing( + listingId: string, + requestingUser: User, + ): Promise { + await this.permissionService.canOrThrow( + requestingUser, + 'jobs', + permissionActions.read, + ); + + const activeJobs = await this.prismaService.backgroundJob.findMany({ + where: { + listingId: listingId, + status: BackgroundJobStatusEnum.processing, + }, + }); + + return mapTo(BackgroundJob, activeJobs); + } + + /** + * 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 { + await this.permissionService.canOrThrow( + requestingUser, + 'jobs', + permissionActions.read, + ); + + const activeJob = await this.prismaService.backgroundJob.findFirst({ + select: { id: true }, + where: { + status: BackgroundJobStatusEnum.processing, + }, + }); + + return { + success: !!activeJob, + }; + } +} 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 0000000000..9d9b69e84d --- /dev/null +++ b/api/test/integration/background-jobs.e2e-spec.ts @@ -0,0 +1,338 @@ +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 an array with 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).toHaveLength(1); + expect(res.body[0]).toMatchObject({ + id: seededJobId, + listingId, + status: BackgroundJobStatusEnum.processing, + inputS3Key: 'test-s3-key', + }); + }); + + 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 }); + + await prisma.backgroundJob.createMany({ + data: [ + { + listingId: newListing.id, + status: BackgroundJobStatusEnum.completed, + 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).toHaveLength(0); + }); + + 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`, + ); + }); + }); + + describe('GET /jobs/active', () => { + 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).toEqual({ + success: 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).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 new file mode 100644 index 0000000000..c4b09f36a0 --- /dev/null +++ b/api/test/unit/services/background-jobs.service.spec.ts @@ -0,0 +1,199 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + ConflictException, + ForbiddenException, + 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`, + ), + ); + }); + }); + + 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, requestingUser); + + expect(result).toBeInstanceOf(BackgroundJob); + expect(result.listingId).toBe(listingId); + expect(result.status).toBe(BackgroundJobStatusEnum.processing); + expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({ + where: { id: jobId }, + }); + }); + + it('should throw NotFoundException when the job does not exist', async () => { + prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(null); + + await expect( + service.getById('non-existent-id', requestingUser), + ).rejects.toThrow( + new NotFoundException(`Job with id: non-existent-id was not found`), + ); + }); + }); + + describe('findActiveForListing', () => { + 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).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, + }, + }); + }); + + 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).toHaveLength(0); + }); + }); + + describe('findActiveJob', () => { + it('should return true when a processing job exists', async () => { + prisma.backgroundJob.findFirst = jest.fn().mockResolvedValue(dbJobRecord); + const result = await service.findActiveJob(requestingUser); + + expect(result).toEqual({ + success: true, + }); + expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({ + select: { id: true }, + 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(requestingUser); + + expect(result).toEqual({ + success: false, + }); + }); + }); +}); diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 3632104060..33a30275e4 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -2556,6 +2556,81 @@ 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) + }) + } + /** + * 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) + }) + } +} + export class MapLayersService { /** * List map layers @@ -10076,6 +10151,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 { /** */ @@ -10964,6 +11084,12 @@ export enum MfaType { "sms" = "sms", "email" = "email", } + +export enum BackgroundJobStatusEnum { + "processing" = "processing", + "completed" = "completed", + "failed" = "failed", +} export enum EnumPropertyFilterParamsComparison { "=" = "=", "<>" = "<>",