diff --git a/api/prisma/migrations/62_income_vouchers_to_string_array/migration.sql b/api/prisma/migrations/62_income_vouchers_to_string_array/migration.sql new file mode 100644 index 00000000000..72f804f8a1a --- /dev/null +++ b/api/prisma/migrations/62_income_vouchers_to_string_array/migration.sql @@ -0,0 +1,34 @@ +-- AlterTable: change income_vouchers from boolean to text array + +ALTER TABLE "applications" +ADD COLUMN "income_vouchers_new" TEXT[] NOT NULL DEFAULT '{}'; + +-- Migrate existing true values to ['incomeVoucher'], false/null to ['none'] +UPDATE "applications" +SET "income_vouchers_new" = ARRAY['incomeVoucher'] +WHERE "income_vouchers" = true; + +UPDATE "applications" +SET "income_vouchers_new" = ARRAY['none'] +WHERE "income_vouchers" = false; + +ALTER TABLE "applications" +DROP COLUMN "income_vouchers"; + +ALTER TABLE "applications" +RENAME COLUMN "income_vouchers_new" TO "income_vouchers"; + +-- AlterTable: change income_vouchers from boolean to text array on snapshot table +ALTER TABLE "application_snapshot" +ADD COLUMN "income_vouchers_new" TEXT[] NOT NULL DEFAULT '{}'; + +-- Migrate existing true values to ['incomeVoucher'], false/null to empty array +UPDATE "application_snapshot" +SET "income_vouchers_new" = ARRAY['incomeVoucher'] +WHERE "income_vouchers" = true; + +ALTER TABLE "application_snapshot" +DROP COLUMN "income_vouchers"; + +ALTER TABLE "application_snapshot" +RENAME COLUMN "income_vouchers_new" TO "income_vouchers"; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 62dc4ab1f43..2b147b50807 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -402,7 +402,7 @@ model Applications { housingStatus String? @map("housing_status") income String? incomePeriod IncomePeriodEnum? @map("income_period") - incomeVouchers Boolean? @map("income_vouchers") + incomeVouchers String[] @map("income_vouchers") // This application is the most recent for the user isNewest Boolean? @default(false) @map("is_newest") language LanguagesEnum? @@ -481,7 +481,7 @@ model ApplicationSnapshot { housingStatus String? @map("housing_status") income String? incomePeriod IncomePeriodEnum? @map("income_period") - incomeVouchers Boolean? @map("income_vouchers") + incomeVouchers String[] @map("income_vouchers") // This application is the most recent for the user isNewest Boolean? @default(false) @map("is_newest") language LanguagesEnum? diff --git a/api/prisma/seed-helpers/application-factory.ts b/api/prisma/seed-helpers/application-factory.ts index 9ef27c4ac84..3baed72c737 100644 --- a/api/prisma/seed-helpers/application-factory.ts +++ b/api/prisma/seed-helpers/application-factory.ts @@ -78,7 +78,7 @@ export const applicationFactory = async (optionalParams?: { ? createdAtDate : dayjs(createdAtDate).add(2, 'days').toDate(), householdSize: householdSize, - income: '40000', + income: randomInt(500, 100000).toString(), incomePeriod: randomBoolean() ? IncomePeriodEnum.perYear : IncomePeriodEnum.perMonth, @@ -132,7 +132,7 @@ export const applicationFactory = async (optionalParams?: { }, } : undefined, - incomeVouchers: randomBoolean(), + incomeVouchers: randomBoolean() ? ['incomeVoucher'] : [], additionalPhoneNumber: includeAdditionalPhone ? optionalParams?.additionalPhone || '(456) 456-4564' : undefined, diff --git a/api/prisma/seed-staging/seed-angelopolis.ts b/api/prisma/seed-staging/seed-angelopolis.ts index fdd0017ae85..3386584bb43 100644 --- a/api/prisma/seed-staging/seed-angelopolis.ts +++ b/api/prisma/seed-staging/seed-angelopolis.ts @@ -101,6 +101,7 @@ export const createAngelopolisJurisdiction = async ( FeatureFlagEnum.disableReservedCommunityTypeEdit, FeatureFlagEnum.enableAccessibilityFeatures, FeatureFlagEnum.enableApplicationStatus, + FeatureFlagEnum.enableAutoOpenDate, FeatureFlagEnum.enableAutopublish, FeatureFlagEnum.enableConfigurableRegions, FeatureFlagEnum.enableCreditScreeningFee, diff --git a/api/prisma/seed-staging/seed-bridge-bay.ts b/api/prisma/seed-staging/seed-bridge-bay.ts index bf9433999d9..f7b9ac77e30 100644 --- a/api/prisma/seed-staging/seed-bridge-bay.ts +++ b/api/prisma/seed-staging/seed-bridge-bay.ts @@ -2411,6 +2411,7 @@ const featureFlags = [ FeatureFlagEnum.enableListingMap, FeatureFlagEnum.enableListingOpportunity, FeatureFlagEnum.enableListingPagination, + FeatureFlagEnum.enableMultiselectVoucherQuestion, FeatureFlagEnum.enablePartnerDemographics, FeatureFlagEnum.enablePartnerSettings, FeatureFlagEnum.enableReceivedAtAndByFields, diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index a852391a09a..c2cbc741f44 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -53,6 +53,7 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; import { ApiKeyGuard } from '../guards/api-key.guard'; import { PublicAppsViewQueryParams } from '../dtos/applications/public-apps-view-params.dto'; import { PublicAppsViewResponse } from '../dtos/applications/public-apps-view-response.dto'; +import { ApplicationBulkUploadService } from '../services/application-bulk-upload.service'; @Controller('applications') @ApiTags('applications') @@ -70,6 +71,7 @@ export class ApplicationController { constructor( private readonly applicationService: ApplicationService, private readonly applicationExportService: ApplicationExporterService, + private readonly applicationBulkUploadService: ApplicationBulkUploadService, ) {} @Get() @@ -204,6 +206,25 @@ export class ApplicationController { return this.applicationService.findOne(applicationId, req); } + @Get('bulk-update/template/:listingId') + @ApiOperation({ + summary: + 'Download a template CSV for bulk updating applications for a listing', + operationId: 'downloadBulkUpdateTemplate', + }) + @Header('Content-Type', 'application/zip') + @UseInterceptors(ExportLogInterceptor) + @ApiOkResponse({ type: StreamableFile }) + async downloadBulkUpdateTemplate( + @Request() req: ExpressRequest, + @Param('listingId') listingId: string, + ): Promise { + return await this.applicationBulkUploadService.downloadBulkUpdateTemplate( + listingId, + mapTo(User, req['user']), + ); + } + @Post() @ApiOperation({ summary: diff --git a/api/src/decorators/validate-listing-publish.decorator.ts b/api/src/decorators/validate-listing-publish.decorator.ts index 7bf59de6053..e096fbafc26 100644 --- a/api/src/decorators/validate-listing-publish.decorator.ts +++ b/api/src/decorators/validate-listing-publish.decorator.ts @@ -22,7 +22,7 @@ class ListingPublishRequiredConstraint implements ValidatorConstraintInterface { /* * Check if the listing is being published. - * A listing is considered "publishing" if its status is 'active' or 'pending review'. + * A listing is considered "publishing" if its status is 'active', 'pending review', or 'scheduled'. * If status is undefined, null, or an empty string we have to assume that it is publishing in order to apply validation * so that strict validation will be applied */ @@ -30,6 +30,7 @@ class ListingPublishRequiredConstraint implements ValidatorConstraintInterface { return ( status === ListingsStatusEnum.active || status === ListingsStatusEnum.pendingReview || + status === ListingsStatusEnum.scheduled || status === undefined || status === null || status === '' diff --git a/api/src/dtos/applications/application.dto.ts b/api/src/dtos/applications/application.dto.ts index 1206741413d..3cb4f408dc2 100644 --- a/api/src/dtos/applications/application.dto.ts +++ b/api/src/dtos/applications/application.dto.ts @@ -114,10 +114,11 @@ export class Application extends AbstractDTO { reasonableAccommodations?: string; @Expose() - @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ each: true, groups: [ValidationsGroupsEnum.default] }) @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) @ApiPropertyOptional() - incomeVouchers?: boolean; + incomeVouchers?: string[]; @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) diff --git a/api/src/enums/feature-flags/feature-flags-enum.ts b/api/src/enums/feature-flags/feature-flags-enum.ts index 112fe2bb46e..c27cb06f556 100644 --- a/api/src/enums/feature-flags/feature-flags-enum.ts +++ b/api/src/enums/feature-flags/feature-flags-enum.ts @@ -14,6 +14,7 @@ export enum FeatureFlagEnum { enableAccessibilityFeatures = 'enableAccessibilityFeatures', enableAdditionalResources = 'enableAdditionalResources', enableApplicationStatus = 'enableApplicationStatus', + enableAutoOpenDate = 'enableAutoOpenDate', enableAutopublish = 'enableAutopublish', enableCompanyWebsite = 'enableCompanyWebsite', enableCustomListingNotifications = 'enableCustomListingNotifications', @@ -43,6 +44,7 @@ export enum FeatureFlagEnum { enableMarketingFlyer = 'enableMarketingFlyer', enableMarketingStatus = 'enableMarketingStatus', enableMarketingStatusMonths = 'enableMarketingStatusMonths', + enableMultiselectVoucherQuestion = 'enableMultiselectVoucherQuestion', enableNeighborhoodAmenities = 'enableNeighborhoodAmenities', enableNeighborhoodAmenitiesDropdown = 'enableNeighborhoodAmenitiesDropdown', enableNonRegulatedListings = 'enableNonRegulatedListings', @@ -143,6 +145,11 @@ export const featureFlagMap: { description: 'When true, the application status and notifications feature is enabled on public and partners', }, + { + name: FeatureFlagEnum.enableAutoOpenDate, + description: + 'When true, partners can set an optional scheduled listing applications open date', + }, { name: FeatureFlagEnum.enableAutopublish, description: @@ -280,6 +287,11 @@ export const featureFlagMap: { description: "When true, the 'marketing status' sub-section uses months instead of seasons (functions only if enableMarketingStatus is also true)", }, + { + name: FeatureFlagEnum.enableMultiselectVoucherQuestion, + description: + 'When true, the vouchers question on the application form becomes a multi-select checkbox experience with Section 8, rental assistance, and none of the above as options', + }, { name: FeatureFlagEnum.enableNeighborhoodAmenities, description: diff --git a/api/src/modules/application-bulk-upload.module.ts b/api/src/modules/application-bulk-upload.module.ts new file mode 100644 index 00000000000..e5916d69482 --- /dev/null +++ b/api/src/modules/application-bulk-upload.module.ts @@ -0,0 +1,13 @@ +import { Global, Module } from '@nestjs/common'; +import { ApplicationBulkUploadService } from '../services/application-bulk-upload.service'; +import { PrismaModule } from './prisma.module'; +import { ListingModule } from './listing.module'; +import { PermissionModule } from './permission.module'; + +@Global() +@Module({ + imports: [PrismaModule, ListingModule, PermissionModule], + providers: [ApplicationBulkUploadService], + exports: [ApplicationBulkUploadService], +}) +export class ApplicationBulkUploadModule {} diff --git a/api/src/modules/application.module.ts b/api/src/modules/application.module.ts index e69ef22b847..6eeb8754c0f 100644 --- a/api/src/modules/application.module.ts +++ b/api/src/modules/application.module.ts @@ -11,10 +11,12 @@ import { PermissionModule } from './permission.module'; import { PrismaModule } from './prisma.module'; import { SnapshotCreateModule } from './snapshot-create.module'; import { UnitTypeModule } from './unit-type.module'; +import { ApplicationBulkUploadModule } from './application-bulk-upload.module'; @Module({ imports: [ ApplicationExporterModule, + ApplicationBulkUploadModule, CronJobModule, EmailModule, ListingModule, diff --git a/api/src/services/application-bulk-upload.service.ts b/api/src/services/application-bulk-upload.service.ts new file mode 100644 index 00000000000..121e8d052e8 --- /dev/null +++ b/api/src/services/application-bulk-upload.service.ts @@ -0,0 +1,320 @@ +import { + ForbiddenException, + Injectable, + StreamableFile, + NotFoundException, +} from '@nestjs/common'; +import { ApplicationStatusEnum } from '@prisma/client'; +import fs, { createReadStream } from 'fs'; +import dayjs from 'dayjs'; +import { join } from 'path'; +import { PrismaService } from './prisma.service'; +import { CsvHeader } from '../types/CsvExportInterface'; +import { formatLocalDate } from '../utilities/format-local-date'; +import { Application } from '../dtos/applications/application.dto'; +import { mapTo } from '../utilities/mapTo'; +import { zipExport } from '../utilities/zip-export'; +import { User } from '../dtos/users/user.dto'; +import { ListingService } from './listing.service'; +import { PermissionService } from './permission.service'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { convertApplicationDeclineReasonToReadable } from '../utilities/application-export-helpers'; + +const NUMBER_TO_PAGINATE_BY = 500; + +@Injectable() +export class ApplicationBulkUploadService { + private dateFormat = 'MM-DD-YYYY hh:mm:ssA z'; + + constructor( + private prisma: PrismaService, + private listingService: ListingService, + private permissionService: PermissionService, + ) {} + + private formatApplicationStatus(statusEnum: ApplicationStatusEnum): string { + switch (statusEnum) { + case ApplicationStatusEnum.declined: + return 'Declined'; + case ApplicationStatusEnum.receivedUnit: + return 'Received a Unit'; + case ApplicationStatusEnum.submitted: + return 'Submitted'; + case ApplicationStatusEnum.waitlist: + return 'Wait list'; + case ApplicationStatusEnum.waitlistDeclined: + return 'Wait list - Declined'; + default: + return statusEnum; + } + } + + private getBulkUploadHeaders(timeZone?: string): CsvHeader[] { + const headers: CsvHeader[] = [ + { + path: 'id', + label: 'Application Id', + }, + { + path: 'applicant.firstName', + label: 'Applicant First Name', + }, + { + path: 'applicant.lastName', + label: 'Applicant Last Name', + }, + { + path: 'submissionDate', + label: 'Application Submission Date', + format: (val: string): string => + formatLocalDate( + val, + this.dateFormat, + timeZone ?? process.env.TIME_ZONE, + ), + }, + { + path: 'manualLotteryPositionNumber', + label: 'Lottery Position Number', + }, + { + path: 'status', + label: 'Application Status', + format: (val) => this.formatApplicationStatus(val), + }, + { + path: 'applicationDeclineReason', + label: 'Application Decline Reason', + format: (val) => convertApplicationDeclineReasonToReadable(val), + }, + { + path: 'applicationDeclineReasonAdditionalDetails', + label: 'Application Decline Reason Additional Details', + }, + { + path: 'accessibleUnitWaitlistNumber', + label: 'Waitlist Position (Accessible Unit)', + }, + { + path: 'conventionalUnitWaitlistNumber', + label: 'Waitlist Position (Conventional Unit)', + }, + ]; + + return headers; + } + + async downloadBulkUpdateTemplate( + listingId: string, + user: User, + ): Promise { + await this.authorizeExport(user, listingId); + + const applications = await this.prisma.applications.findMany({ + select: { + id: true, + }, + where: { + listingId: listingId, + markedAsDuplicate: false, + deletedAt: null, + }, + }); + + if (applications.length === 0) { + throw new NotFoundException( + `Listing with id: ${listingId} does not contain valid applications`, + ); + } + + const now = new Date(); + const dateString = dayjs(now).format('YYYY-MM-DD_HH-mm'); + + const zipFilename = `listing-${listingId}-applications-${ + user.id + }-${now.getTime()}`; + + const filename = join(process.cwd(), `src/temp/${zipFilename}.csv`); + + await this.csvExportHelper( + filename, + listingId, + mapTo(Application, applications), + ); + const readStream = createReadStream(filename); + + return await zipExport( + readStream, + zipFilename, + `applications-${listingId}-${dateString}`, + false, + ); + } + + csvExportHelper( + filename: string, + listingId: string, + applications: Pick[], + ): Promise { + const csvHeaders = this.getBulkUploadHeaders(); + + return new Promise(async (resolve, reject) => { + // create stream + const writableStream = fs.createWriteStream(`${filename}`); + writableStream + .on('error', (err) => { + console.log('csv writestream error'); + console.log(err); + reject(err); + }) + .on('close', () => { + resolve(); + }) + .on('open', async () => { + try { + writableStream.write( + csvHeaders + .map((header) => `"${header.label.replace(/"/g, `""`)}"`) + .join(',') + '\n', + ); + + const promiseArray: Promise[] = []; + for ( + let i = 0; + i < applications.length; + i += NUMBER_TO_PAGINATE_BY + ) { + promiseArray.push( + (async () => { + const paginatedApplications = + await this.prisma.applications.findMany({ + select: { + id: true, + applicant: { + select: { + firstName: true, + lastName: true, + }, + }, + submissionDate: true, + manualLotteryPositionNumber: true, + status: true, + applicationDeclineReason: true, + applicationDeclineReasonAdditionalDetails: true, + accessibleUnitWaitlistNumber: true, + conventionalUnitWaitlistNumber: true, + }, + where: { + listingId: listingId, + markedAsDuplicate: false, + deletedAt: null, + id: { + in: applications + .slice(i, i + NUMBER_TO_PAGINATE_BY) + .map((app) => app.id), + }, + }, + }); + + let data = ''; + paginatedApplications.forEach((app) => { + const stringData = this.populateDataForEachHeader( + csvHeaders, + app, + { stringData: data }, + ); + data = stringData + '\n'; + }); + return data; + })(), + ); + } + const resolvedArray = await Promise.all(promiseArray); + // now loop over batched row data and write them to file + resolvedArray.forEach((row) => { + try { + writableStream.write(row); + } catch (e) { + console.log('writeStream write error = ', e); + writableStream.once('drain', () => { + console.log('drain buffer'); + writableStream.write(row + '\n'); + }); + } + }); + writableStream.end(); + } catch (e) { + reject(e); + } + }); + }); + } + + populateDataForEachHeader( + csvHeaders: CsvHeader[], + application, + optionalParams?: { + stringData?: string; + }, + ): string { + let stringData = optionalParams?.stringData ?? ''; + + csvHeaders.forEach((header, index) => { + let value; + value = header.path.split('.').reduce((acc, curr) => { + if (acc === null || acc === undefined) { + return ''; + } + + if (!isNaN(Number(curr))) { + const index = Number(curr); + return acc[index]; + } + + return acc[curr]; + }, application); + + if (value === undefined) { + value = ''; + } else if (value === null) { + value = ''; + } + + if (header.format) { + value = header.format(value); + } + + if (stringData !== undefined) { + stringData += + value !== '' ? `"${value.toString().replace(/"/g, `""`)}"` : ''; + if (index < csvHeaders.length - 1) { + stringData += ','; + } + } + }); + return stringData; + } + + async authorizeExport(user, listingId): Promise { + /** + * Checking authorization for each application is very expensive. + * By making listingId required, we can check if the user has update permissions for the listing, since right now if a user has that + * they also can run the export for that listing + */ + if (user?.userRoles?.isLimitedJurisdictionalAdmin) + throw new ForbiddenException(); + + const jurisdictionId = + await this.listingService.getJurisdictionIdByListingId(listingId); + + await this.permissionService.canOrThrow( + user, + 'listing', + permissionActions.update, + { + id: listingId, + jurisdictionId, + }, + ); + } +} diff --git a/api/src/services/application-exporter.service.ts b/api/src/services/application-exporter.service.ts index 67e84d57489..61644cb1e73 100644 --- a/api/src/services/application-exporter.service.ts +++ b/api/src/services/application-exporter.service.ts @@ -245,6 +245,10 @@ export class ApplicationExporterService { jurisdiction as Jurisdiction, FeatureFlagEnum.swapCommunityTypeWithPrograms, ); + const enableMultiselectVoucherQuestion = doJurisdictionHaveFeatureFlagSet( + jurisdiction as Jurisdiction, + FeatureFlagEnum.enableMultiselectVoucherQuestion, + ); // get all multiselect questions for a listing to build csv headers const multiSelectQuestions = @@ -267,6 +271,7 @@ export class ApplicationExporterService { enableApplicationStatus, enableFullTimeStudentQuestion, enableReasonableAccommodations, + enableMultiselectVoucherQuestion, enableSpokenLanguage, enableGenderQuestion, enableSexualOrientationQuestion, @@ -538,6 +543,10 @@ export class ApplicationExporterService { jurisdiction as Jurisdiction, FeatureFlagEnum.swapCommunityTypeWithPrograms, ); + const enableMultiselectVoucherQuestion = doJurisdictionHaveFeatureFlagSet( + jurisdiction as Jurisdiction, + FeatureFlagEnum.enableMultiselectVoucherQuestion, + ); // get all multiselect questions for a listing to build csv headers const multiSelectQuestions = @@ -560,6 +569,7 @@ export class ApplicationExporterService { enableApplicationStatus, enableFullTimeStudentQuestion, enableReasonableAccommodations, + enableMultiselectVoucherQuestion, enableGenderQuestion, enableSexualOrientationQuestion, enableV2MSQ, diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts index a347b0590af..bae88ba414c 100644 --- a/api/src/services/listing.service.ts +++ b/api/src/services/listing.service.ts @@ -1504,8 +1504,16 @@ export class ListingService implements OnModuleInit { FeatureFlagEnum.enableAutopublish, ); + const enableAutoOpenDate = doJurisdictionHaveFeatureFlagSet( + rawJurisdiction as unknown as Jurisdiction, + FeatureFlagEnum.enableAutoOpenDate, + ); + if (!enableAutopublish) { dto.scheduledPublishAt = null; + } + + if (!enableAutoOpenDate) { dto.scheduledApplicationOpenAt = null; } @@ -1519,7 +1527,7 @@ export class ListingService implements OnModuleInit { ); } - if (enableAutopublish && dto.scheduledApplicationOpenAt) { + if (enableAutoOpenDate && dto.scheduledApplicationOpenAt) { dto.scheduledApplicationOpenAt = this.normalizeScheduledApplicationOpenAt( dto.scheduledApplicationOpenAt, ); @@ -2356,8 +2364,16 @@ export class ListingService implements OnModuleInit { FeatureFlagEnum.enableAutopublish, ); + const enableAutoOpenDate = doJurisdictionHaveFeatureFlagSet( + rawJurisdiction as Jurisdiction, + FeatureFlagEnum.enableAutoOpenDate, + ); + if (!enableAutopublish) { incomingDto.scheduledPublishAt = null; + } + + if (!enableAutoOpenDate) { incomingDto.scheduledApplicationOpenAt = null; } @@ -2371,8 +2387,9 @@ export class ListingService implements OnModuleInit { : null; // test if not publishing or unpublishing listing and scheduledPublishAt is set if ( - incomingDto.status === storedListing.status && - incomingDto.status !== ListingsStatusEnum.active && + storedListing.status !== ListingsStatusEnum.active && + (incomingDto.status === storedListing.status || + incomingDto.status === ListingsStatusEnum.pendingReview) && incomingDto.scheduledPublishAt ) { this.checkScheduledDateIsInFuture( @@ -2380,20 +2397,21 @@ export class ListingService implements OnModuleInit { 'scheduledPublishAt', ); } + } - if (incomingDto.scheduledApplicationOpenAt) { + if (enableAutoOpenDate && incomingDto.scheduledApplicationOpenAt) { + incomingDto.scheduledApplicationOpenAt = this.normalizeScheduledApplicationOpenAt( incomingDto.scheduledApplicationOpenAt, ); - this.checkScheduledDateIsInFuture( - incomingDto.scheduledApplicationOpenAt, - 'scheduledApplicationOpenAt', - ); - this.checkScheduledApplicationOpenAtIsAfterPublish( - incomingDto.scheduledApplicationOpenAt, - incomingDto.scheduledPublishAt, - ); - } + this.checkScheduledDateIsInFuture( + incomingDto.scheduledApplicationOpenAt, + 'scheduledApplicationOpenAt', + ); + this.checkScheduledApplicationOpenAtIsAfterPublish( + incomingDto.scheduledApplicationOpenAt, + incomingDto.scheduledPublishAt, + ); } if ( @@ -2925,11 +2943,11 @@ export class ListingService implements OnModuleInit { }, } : undefined, - publishedAt: - storedListing.status !== ListingsStatusEnum.active && - incomingDto.status === ListingsStatusEnum.active - ? new Date() - : storedListing.publishedAt, + publishedAt: ListingService.resolvePublishedAt( + incomingDto.status, + storedListing.status, + storedListing.publishedAt, + ), closedAt: storedListing.status !== ListingsStatusEnum.closed && incomingDto.status === ListingsStatusEnum.closed @@ -3349,9 +3367,26 @@ export class ListingService implements OnModuleInit { }; normalizeScheduledPublishAt(scheduledPublishAt: Date): Date { - const appTimezone = process.env.TIME_ZONE; + const appTimezone = process.env.TIME_ZONE || 'America/Los_Angeles'; const dateStr = dayjs.utc(scheduledPublishAt).format('YYYY-MM-DD'); - return dayjs.tz(dateStr, 'YYYY-MM-DD', appTimezone).startOf('day').toDate(); + return dayjs.tz(`${dateStr}T00:00:00`, appTimezone).toDate(); + } + + private static resolvePublishedAt( + incomingStatus: ListingsStatusEnum, + storedStatus: ListingsStatusEnum, + storedPublishedAt: Date | null, + ): Date | null { + if ( + incomingStatus === ListingsStatusEnum.active && + storedStatus !== ListingsStatusEnum.active + ) { + return new Date(); + } + if (incomingStatus === ListingsStatusEnum.pending) { + return null; + } + return storedPublishedAt ?? null; } private checkScheduledDateIsInFuture(date: Date, fieldName: string): void { @@ -3364,14 +3399,10 @@ export class ListingService implements OnModuleInit { } normalizeScheduledApplicationOpenAt(scheduledApplicationOpenAt: Date): Date { - const appTimezone = process.env.TIME_ZONE; + const appTimezone = process.env.TIME_ZONE || 'America/Los_Angeles'; const dateStr = dayjs.utc(scheduledApplicationOpenAt).format('YYYY-MM-DD'); // Applications open at 9:00 AM in the app timezone - return dayjs - .tz(dateStr, 'YYYY-MM-DD', appTimezone) - .startOf('day') - .add(9, 'hour') - .toDate(); + return dayjs.tz(`${dateStr}T00:00:00`, appTimezone).add(9, 'hour').toDate(); } private checkScheduledApplicationOpenAtIsAfterPublish( diff --git a/api/src/utilities/application-export-helpers.ts b/api/src/utilities/application-export-helpers.ts index 678389108ae..0480a0ad6ce 100644 --- a/api/src/utilities/application-export-helpers.ts +++ b/api/src/utilities/application-export-helpers.ts @@ -34,6 +34,7 @@ export const getExportHeaders = ( enableApplicationStatus?: boolean; enableFullTimeStudentQuestion?: boolean; enableReasonableAccommodations?: boolean; + enableMultiselectVoucherQuestion?: boolean; enableSpokenLanguage?: boolean; enableGenderQuestion?: boolean; enableSexualOrientationQuestion?: boolean; @@ -51,6 +52,7 @@ export const getExportHeaders = ( enableApplicationStatus, enableFullTimeStudentQuestion, enableReasonableAccommodations, + enableMultiselectVoucherQuestion, enableSpokenLanguage, enableGenderQuestion, enableSexualOrientationQuestion, @@ -416,6 +418,18 @@ export const getExportHeaders = ( { path: 'incomeVouchers', label: 'Vouchers or Subsidies', + format: (val: string[]): string => { + if (!enableMultiselectVoucherQuestion) { + return (!val && val.length === 0) || val[0] === 'none' + ? 'No' + : 'Yes'; + } + if (!val || val.length === 0) { + return 'None'; + } + + return val.map((v) => incomeVouchersToReadable(v)).join(', '); + }, }, { path: 'preferredUnitTypes', @@ -1086,3 +1100,21 @@ export const typeMap = { export const unitTypeToReadable = (type: string): string => { return typeMap[type] ?? type; }; + +/** + * @param type value of the income voucher type to convert to readable string + * @returns the string representation of that income voucher type + */ +export const incomeVouchersToReadable = (type: string): string => { + switch (type) { + case 'issuedVouchers': + return 'Section 8'; + case 'rentalAssistance': + return 'Rental Assistance from other source'; + case 'incomeVoucher': + return 'Income Voucher'; + case 'none': + default: + return 'None'; + } +}; diff --git a/api/test/integration/application.e2e-spec.ts b/api/test/integration/application.e2e-spec.ts index 5a2e7cf74db..8ac17beab26 100644 --- a/api/test/integration/application.e2e-spec.ts +++ b/api/test/integration/application.e2e-spec.ts @@ -120,7 +120,7 @@ describe('Application Controller Tests', () => { housingStatus: 'example status', householdExpectingChanges: false, householdStudent: false, - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, @@ -1649,7 +1649,7 @@ describe('Application Controller Tests', () => { sendMailToMailingAddress: true, householdExpectingChanges: false, householdStudent: false, - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, @@ -1848,7 +1848,7 @@ describe('Application Controller Tests', () => { householdSize: 2, householdStudent: false, housingStatus: 'example status', - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, @@ -2022,7 +2022,7 @@ describe('Application Controller Tests', () => { householdSize: 2, householdStudent: false, housingStatus: 'example status', - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, @@ -2183,7 +2183,7 @@ describe('Application Controller Tests', () => { householdSize: 2, householdStudent: false, housingStatus: 'example status', - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, @@ -2345,7 +2345,7 @@ describe('Application Controller Tests', () => { sendMailToMailingAddress: true, householdExpectingChanges: false, householdStudent: false, - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, @@ -2563,7 +2563,7 @@ describe('Application Controller Tests', () => { sendMailToMailingAddress: true, householdExpectingChanges: false, householdStudent: false, - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, diff --git a/api/test/integration/permission-tests/helpers.ts b/api/test/integration/permission-tests/helpers.ts index 5985cfff6a8..db5fec02bd4 100644 --- a/api/test/integration/permission-tests/helpers.ts +++ b/api/test/integration/permission-tests/helpers.ts @@ -400,7 +400,7 @@ export const buildApplicationCreateMock = ( sendMailToMailingAddress: true, householdExpectingChanges: false, householdStudent: false, - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, @@ -499,7 +499,7 @@ export const buildApplicationUpdateMock = ( sendMailToMailingAddress: true, householdExpectingChanges: false, householdStudent: false, - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, diff --git a/api/test/integration/snapshot-create.e2e-spec.ts b/api/test/integration/snapshot-create.e2e-spec.ts index fd56e110617..03aabb4f550 100644 --- a/api/test/integration/snapshot-create.e2e-spec.ts +++ b/api/test/integration/snapshot-create.e2e-spec.ts @@ -1646,7 +1646,7 @@ describe('Snapshot Create Controller Tests', () => { housingStatus: 'example housingStatus', income: 'example income', incomePeriod: IncomePeriodEnum.perMonth, - incomeVouchers: true, + incomeVouchers: ['incomeVoucher'], isNewest: true, language: LanguagesEnum.bn, manualLotteryPositionNumber: 34, @@ -1709,7 +1709,7 @@ describe('Snapshot Create Controller Tests', () => { housingStatus: 'example housingStatus', income: 'example income', incomePeriod: IncomePeriodEnum.perMonth, - incomeVouchers: true, + incomeVouchers: ['incomeVoucher'], isNewest: true, language: LanguagesEnum.bn, manualLotteryPositionNumber: 34, @@ -1902,7 +1902,7 @@ describe('Snapshot Create Controller Tests', () => { housingStatus: 'example housingStatus', income: 'example income', incomePeriod: IncomePeriodEnum.perMonth, - incomeVouchers: true, + incomeVouchers: ['incomeVoucher'], isNewest: true, language: LanguagesEnum.bn, manualLotteryPositionNumber: 34, @@ -2257,7 +2257,7 @@ describe('Snapshot Create Controller Tests', () => { housingStatus: 'example housingStatus', income: 'example income', incomePeriod: IncomePeriodEnum.perMonth, - incomeVouchers: true, + incomeVouchers: ['incomeVoucher'], isNewest: true, language: LanguagesEnum.bn, manualLotteryPositionNumber: 34, diff --git a/api/test/unit/decorators/validate-listing-publish.spec.ts b/api/test/unit/decorators/validate-listing-publish.spec.ts new file mode 100644 index 00000000000..0e9ae48e541 --- /dev/null +++ b/api/test/unit/decorators/validate-listing-publish.spec.ts @@ -0,0 +1,47 @@ +import { ListingsStatusEnum } from '@prisma/client'; +import { validateSync } from 'class-validator'; +import { ValidateListingPublish } from '../../../src/decorators/validate-listing-publish.decorator'; + +class ListingPublishValidationTestDto { + status?: ListingsStatusEnum; + requiredFields?: string[]; + + @ValidateListingPublish('units') + units?: unknown[]; +} + +describe('ValidateListingPublish', () => { + it('should require fields for scheduled status the same as publish', () => { + const dto = new ListingPublishValidationTestDto(); + dto.status = ListingsStatusEnum.scheduled; + dto.requiredFields = ['units']; + dto.units = undefined; + + const errors = validateSync(dto); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('units'); + }); + + it('should pass for scheduled status when required field is present', () => { + const dto = new ListingPublishValidationTestDto(); + dto.status = ListingsStatusEnum.scheduled; + dto.requiredFields = ['units']; + dto.units = [{ id: 'unit-1' }]; + + const errors = validateSync(dto); + + expect(errors).toHaveLength(0); + }); + + it('should not require field when not included in requiredFields', () => { + const dto = new ListingPublishValidationTestDto(); + dto.status = ListingsStatusEnum.scheduled; + dto.requiredFields = ['buildingAddress']; + dto.units = undefined; + + const errors = validateSync(dto); + + expect(errors).toHaveLength(0); + }); +}); diff --git a/api/test/unit/services/application-bulk-upload.service.spec.ts b/api/test/unit/services/application-bulk-upload.service.spec.ts new file mode 100644 index 00000000000..5ae351a6af7 --- /dev/null +++ b/api/test/unit/services/application-bulk-upload.service.spec.ts @@ -0,0 +1,244 @@ +import fs from 'fs'; +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + ApplicationDeclineReasonEnum, + ApplicationStatusEnum, + ApplicationSubmissionTypeEnum, +} from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { addressFactory } from '../../../prisma/seed-helpers/address-factory'; +import { Address } from '../../../src/dtos/addresses/address.dto'; +import { Accessibility } from '../../../src/dtos/applications/accessibility.dto'; +import { AlternateContact } from '../../../src/dtos/applications/alternate-contact.dto'; +import { Applicant } from '../../../src/dtos/applications/applicant.dto'; +import { Application } from '../../../src/dtos/applications/application.dto'; +import { Demographic } from '../../../src/dtos/applications/demographic.dto'; +import { ApplicationBulkUploadService } from '../../../src/services/application-bulk-upload.service'; +import { ListingService } from '../../../src/services/listing.service'; +import { PermissionService } from '../../../src/services/permission.service'; +import { PrismaService } from '../../../src/services/prisma.service'; + +const mockApplication = ({ + markedAsDuplicate = false, + applicant = { + id: randomUUID(), + applicantAddress: addressFactory() as unknown as Address, + applicantWorkAddress: addressFactory() as unknown as Address, + }, + ...options +}: { + id?: string; + applicant?: Partial; + submissionDate?: Date; + deletedAt?: Date; + status?: ApplicationStatusEnum; + applicationDeclineReason?: ApplicationDeclineReasonEnum; + applicationDeclineReasonAdditionalDetails?: string; + accessibleUnitWaitlistNumber?: number; + conventionalUnitWaitlistNumber?: number; + markedAsDuplicate?: boolean; + position?: number; + manualLotteryPositionNumber?: number; +}): Application => { + return { + id: options?.id || randomUUID(), + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: options?.deletedAt ?? null, + submissionDate: options?.submissionDate ?? new Date(), + contactPreferences: ['example contact preference'], + status: options?.status ?? ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + markedAsDuplicate: markedAsDuplicate, + confirmationCode: `confirmationCode ${options?.position}`, + applicant: applicant as Applicant, + manualLotteryPositionNumber: options?.manualLotteryPositionNumber ?? null, + applicationLotteryPositions: [], + applicationsMailingAddress: addressFactory() as unknown as Address, + applicationsAlternateAddress: addressFactory() as unknown as Address, + accessibility: {} as Accessibility, + demographics: { howDidYouHear: [] } as unknown as Demographic, + preferredUnitTypes: [], + alternateContact: { + address: addressFactory() as unknown as Address, + } as unknown as AlternateContact, + householdMember: [], + listings: { id: randomUUID() }, + applicationDeclineReason: options?.applicationDeclineReason ?? null, + applicationDeclineReasonAdditionalDetails: + options?.applicationDeclineReasonAdditionalDetails ?? null, + accessibleUnitWaitlistNumber: options?.accessibleUnitWaitlistNumber, + conventionalUnitWaitlistNumber: options?.conventionalUnitWaitlistNumber, + }; +}; + +const canOrThrowMock = jest.fn(); +const listingServiceMock = { getJurisdictionIdByListingId: jest.fn() }; + +describe('Testing application bulk upload services', () => { + let service: ApplicationBulkUploadService; + let prisma: PrismaService; + let writeStream; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApplicationBulkUploadService, + PrismaService, + { provide: ListingService, useValue: listingServiceMock }, + { + provide: PermissionService, + useValue: { canOrThrow: canOrThrowMock }, + }, + ], + }).compile(); + + service = module.get( + ApplicationBulkUploadService, + ); + prisma = module.get(PrismaService); + }); + + describe('bulk update template csv export', () => { + beforeEach(() => { + writeStream = fs.createWriteStream('sampleTemplate.csv'); + jest.spyOn(fs, 'createWriteStream').mockReturnValue(writeStream); + }); + + afterEach(() => { + writeStream.end(); + if (fs.existsSync('sampleTemplate.csv')) { + fs.unlinkSync('sampleTemplate.csv'); + } + jest.restoreAllMocks(); + }); + + const applicationsSet = [ + mockApplication({ + id: randomUUID(), + position: 1, + submissionDate: new Date(2026, 4, 19, 22, 0, 0), + applicant: { + firstName: 'Colleen', + lastName: 'Tawnee', + }, + status: ApplicationStatusEnum.declined, + manualLotteryPositionNumber: 15, + applicationDeclineReason: + ApplicationDeclineReasonEnum.householdSizeTooLarge, + applicationDeclineReasonAdditionalDetails: 'Some additional details', + }), + mockApplication({ + id: randomUUID(), + position: 2, + submissionDate: new Date(2026, 3, 2, 10, 0, 0), + applicant: { + firstName: 'Erin', + lastName: 'Patsy', + }, + status: ApplicationStatusEnum.submitted, + accessibleUnitWaitlistNumber: 2, + }), + mockApplication({ + id: randomUUID(), + position: 3, + submissionDate: new Date(2026, 6, 23, 15, 30, 0), + applicant: { + firstName: 'Nanny', + lastName: 'Hayley', + }, + status: ApplicationStatusEnum.waitlist, + conventionalUnitWaitlistNumber: 5, + }), + ]; + + it('should generate a valid template CSV file', async () => { + const mockListingId = randomUUID(); + prisma.applications.findMany = jest + .fn() + .mockResolvedValue(applicationsSet); + + await service.csvExportHelper( + 'sampleTemplate.csv', + mockListingId, + applicationsSet.map((app) => ({ id: app.id })), + ); + + expect(writeStream.bytesWritten).toBeGreaterThan(0); + const content = fs.readFileSync('sampleTemplate.csv', 'utf8'); + + const headers = + '"Application Id","Applicant First Name","Applicant Last Name","Application Submission Date","Lottery Position Number","Application Status","Application Decline Reason","Application Decline Reason Additional Details","Waitlist Position (Accessible Unit)","Waitlist Position (Conventional Unit)"'; + + const rowOne = `"${applicationsSet[0].id}","Colleen","Tawnee","05-19-2026 03:00:00PM PDT","15","Declined","Household size too large","Some additional details",,`; + const rowTwo = `"${applicationsSet[1].id}","Erin","Patsy","04-02-2026 03:00:00AM PDT",,"Submitted",,,"2",`; + const rowThree = `"${applicationsSet[2].id}","Nanny","Hayley","07-23-2026 08:30:00AM PDT",,"Wait list",,,,"5"`; + + expect(content).toContain(headers); + expect(content).toContain(rowOne); + expect(content).toContain(rowTwo); + expect(content).toContain(rowThree); + }); + }); + + describe('authorizeExport', () => { + const listingId = randomUUID(); + const jurisdictionId = randomUUID(); + + beforeEach(() => { + listingServiceMock.getJurisdictionIdByListingId.mockResolvedValue( + jurisdictionId, + ); + canOrThrowMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + listingServiceMock.getJurisdictionIdByListingId.mockReset(); + canOrThrowMock.mockReset(); + }); + + it('should throw ForbiddenException immediately for isLimitedJurisdictionalAdmin users', async () => { + const user = { userRoles: { isLimitedJurisdictionalAdmin: true } }; + + await expect(service.authorizeExport(user, listingId)).rejects.toThrow( + ForbiddenException, + ); + + expect( + listingServiceMock.getJurisdictionIdByListingId, + ).not.toHaveBeenCalled(); + expect(canOrThrowMock).not.toHaveBeenCalled(); + }); + + it('should call listingService.getJurisdictionIdByListingId with the correct listingId', async () => { + const user = { userRoles: { isLimitedJurisdictionalAdmin: false } }; + + await service.authorizeExport(user, listingId); + + expect( + listingServiceMock.getJurisdictionIdByListingId, + ).toHaveBeenCalledWith(listingId); + }); + + it('should call permissionService.canOrThrow with listing, update, and resolved jurisdictionId', async () => { + const user = { userRoles: { isLimitedJurisdictionalAdmin: false } }; + + await service.authorizeExport(user, listingId); + + expect(canOrThrowMock).toHaveBeenCalledWith(user, 'listing', 'update', { + id: listingId, + jurisdictionId, + }); + }); + + it('should re-throw ForbiddenException when canOrThrow rejects', async () => { + const user = { userRoles: { isLimitedJurisdictionalAdmin: false } }; + canOrThrowMock.mockRejectedValue(new ForbiddenException()); + + await expect(service.authorizeExport(user, listingId)).rejects.toThrow( + ForbiddenException, + ); + }); + }); +}); diff --git a/api/test/unit/services/application-exporter.service.spec.ts b/api/test/unit/services/application-exporter.service.spec.ts index df138db7f07..898f8181c88 100644 --- a/api/test/unit/services/application-exporter.service.spec.ts +++ b/api/test/unit/services/application-exporter.service.spec.ts @@ -127,7 +127,7 @@ describe('Testing application export service', () => { const headerRow = '"Application Id","Application Confirmation Code","Application Type","Application Submission Date","Primary Applicant First Name","Primary Applicant Middle Name","Primary Applicant Last Name","Primary Applicant Birth Day","Primary Applicant Birth Month","Primary Applicant Birth Year","Primary Applicant Email Address","Primary Applicant Phone Number","Primary Applicant Phone Type","Primary Applicant Additional Phone Number","Primary Applicant Preferred Contact Type","Primary Applicant Work in Region","Primary Applicant Street","Primary Applicant Street 2","Primary Applicant City","Primary Applicant State","Primary Applicant Zip Code","Primary Applicant Mailing Street","Primary Applicant Mailing Street 2","Primary Applicant Mailing City","Primary Applicant Mailing State","Primary Applicant Mailing Zip Code","Primary Applicant Work Street","Primary Applicant Work Street 2","Primary Applicant Work City","Primary Applicant Work State","Primary Applicant Work Zip Code","Alternate Contact First Name","Alternate Contact Last Name","Alternate Contact Type","Alternate Contact Agency","Alternate Contact Other Type","Alternate Contact Email Address","Alternate Contact Phone Number","Alternate Contact Street","Alternate Contact Street 2","Alternate Contact City","Alternate Contact State","Alternate Contact Zip Code","Income","Income Period","Accessibility Mobility","Accessibility Vision","Accessibility Hearing","Accessibility Hearing and Vision","Accessibility Other","Expecting Household Changes","Household Includes Student or Member Nearing 18","Reasonable Accommodations","Vouchers or Subsidies","Requested Unit Types","Preference text 0","Preference text 0 - text - Address","Program text 1","Household Size","Household Member (1) First Name","Household Member (1) Middle Name","Household Member (1) Last Name","Household Member (1) Birth Day","Household Member (1) Birth Month","Household Member (1) Birth Year","Household Member (1) Relationship","Household Member (1) Same as Primary Applicant","Household Member (1) Work in Region","Household Member (1) Street","Household Member (1) Street 2","Household Member (1) City","Household Member (1) State","Household Member (1) Zip Code","Marked As Duplicate","Flagged As Duplicate"'; const firstApp = - '"application 0 firstName","application 0 middleName","application 0 lastName","application 0 birthDay","application 0 birthMonth","application 0 birthYear","application 0 emailaddress","application 0 phoneNumber","application 0 phoneNumberType","additionalPhoneNumber 0",,"yes","application 0 applicantAddress street","application 0 applicantAddress street2","application 0 applicantAddress city","application 0 applicantAddress state","application 0 applicantAddress zipCode","application 0 mailingAddress street","application 0 mailingAddress street2","application 0 mailingAddress city","application 0 mailingAddress state","application 0 mailingAddress zipCode","application 0 applicantWorkAddress street","application 0 applicantWorkAddress street2","application 0 applicantWorkAddress city","application 0 applicantWorkAddress state","application 0 applicantWorkAddress zipCode","application 0 alternateContact firstName","application 0 alternateContact lastName","application 0 alternateContact type","application 0 alternateContact agency","application 0 alternateContact otherType","application 0 alternatecontact emailaddress","application 0 alternateContact phoneNumber","application 0 alternateContact address street","application 0 alternateContact address street2","application 0 alternateContact address city","application 0 alternateContact address state","application 0 alternateContact address zipCode","income 0","per month",,,,,,"true","true",,"true","Studio,One Bedroom",,,,,,,,,,,,,,,,,,,,'; + '"application 0 firstName","application 0 middleName","application 0 lastName","application 0 birthDay","application 0 birthMonth","application 0 birthYear","application 0 emailaddress","application 0 phoneNumber","application 0 phoneNumberType","additionalPhoneNumber 0",,"yes","application 0 applicantAddress street","application 0 applicantAddress street2","application 0 applicantAddress city","application 0 applicantAddress state","application 0 applicantAddress zipCode","application 0 mailingAddress street","application 0 mailingAddress street2","application 0 mailingAddress city","application 0 mailingAddress state","application 0 mailingAddress zipCode","application 0 applicantWorkAddress street","application 0 applicantWorkAddress street2","application 0 applicantWorkAddress city","application 0 applicantWorkAddress state","application 0 applicantWorkAddress zipCode","application 0 alternateContact firstName","application 0 alternateContact lastName","application 0 alternateContact type","application 0 alternateContact agency","application 0 alternateContact otherType","application 0 alternatecontact emailaddress","application 0 alternateContact phoneNumber","application 0 alternateContact address street","application 0 alternateContact address street2","application 0 alternateContact address city","application 0 alternateContact address state","application 0 alternateContact address zipCode","income 0","per month",,,,,,"true","true",,"Yes","Studio,One Bedroom",,,,,,,,,,,,,,,,,,,,'; const mockedStream = new PassThrough(); exportResponse.pipe(mockedStream); @@ -191,7 +191,7 @@ describe('Testing application export service', () => { const headerRow = '"Application Id","Application Confirmation Code","Application Type","Application Submission Date","Primary Applicant First Name","Primary Applicant Middle Name","Primary Applicant Last Name","Primary Applicant Birth Day","Primary Applicant Birth Month","Primary Applicant Birth Year","Primary Applicant Email Address","Primary Applicant Phone Number","Primary Applicant Phone Type","Primary Applicant Additional Phone Number","Primary Applicant Preferred Contact Type","Primary Applicant Work in Region","Primary Applicant Street","Primary Applicant Street 2","Primary Applicant City","Primary Applicant State","Primary Applicant Zip Code","Primary Applicant Mailing Street","Primary Applicant Mailing Street 2","Primary Applicant Mailing City","Primary Applicant Mailing State","Primary Applicant Mailing Zip Code","Primary Applicant Work Street","Primary Applicant Work Street 2","Primary Applicant Work City","Primary Applicant Work State","Primary Applicant Work Zip Code","Alternate Contact First Name","Alternate Contact Last Name","Alternate Contact Type","Alternate Contact Agency","Alternate Contact Other Type","Alternate Contact Email Address","Alternate Contact Phone Number","Alternate Contact Street","Alternate Contact Street 2","Alternate Contact City","Alternate Contact State","Alternate Contact Zip Code","Income","Income Period","Accessibility Mobility","Accessibility Vision","Accessibility Hearing","Accessibility Hearing and Vision","Accessibility Other","Expecting Household Changes","Household Includes Student or Member Nearing 18","Reasonable Accommodations","Vouchers or Subsidies","Requested Unit Types","Preference text 0","Program text 1","Household Size","Marked As Duplicate","Flagged As Duplicate","Ethnicity","Race","How did you Hear?"'; const firstApp = - '"application 0 firstName","application 0 middleName","application 0 lastName","application 0 birthDay","application 0 birthMonth","application 0 birthYear","application 0 emailaddress","application 0 phoneNumber","application 0 phoneNumberType","additionalPhoneNumber 0",,"yes","application 0 applicantAddress street","application 0 applicantAddress street2","application 0 applicantAddress city","application 0 applicantAddress state","application 0 applicantAddress zipCode","application 0 mailingAddress street","application 0 mailingAddress street2","application 0 mailingAddress city","application 0 mailingAddress state","application 0 mailingAddress zipCode","application 0 applicantWorkAddress street","application 0 applicantWorkAddress street2","application 0 applicantWorkAddress city","application 0 applicantWorkAddress state","application 0 applicantWorkAddress zipCode","application 0 alternateContact firstName","application 0 alternateContact lastName","application 0 alternateContact type","application 0 alternateContact agency","application 0 alternateContact otherType","application 0 alternatecontact emailaddress","application 0 alternateContact phoneNumber","application 0 alternateContact address street","application 0 alternateContact address street2","application 0 alternateContact address city","application 0 alternateContact address state","application 0 alternateContact address zipCode","income 0","per month",,,,,,"true","true",,"true","Studio,One Bedroom",,,,,,,"Decline to Respond"'; + '"application 0 firstName","application 0 middleName","application 0 lastName","application 0 birthDay","application 0 birthMonth","application 0 birthYear","application 0 emailaddress","application 0 phoneNumber","application 0 phoneNumberType","additionalPhoneNumber 0",,"yes","application 0 applicantAddress street","application 0 applicantAddress street2","application 0 applicantAddress city","application 0 applicantAddress state","application 0 applicantAddress zipCode","application 0 mailingAddress street","application 0 mailingAddress street2","application 0 mailingAddress city","application 0 mailingAddress state","application 0 mailingAddress zipCode","application 0 applicantWorkAddress street","application 0 applicantWorkAddress street2","application 0 applicantWorkAddress city","application 0 applicantWorkAddress state","application 0 applicantWorkAddress zipCode","application 0 alternateContact firstName","application 0 alternateContact lastName","application 0 alternateContact type","application 0 alternateContact agency","application 0 alternateContact otherType","application 0 alternatecontact emailaddress","application 0 alternateContact phoneNumber","application 0 alternateContact address street","application 0 alternateContact address street2","application 0 alternateContact address city","application 0 alternateContact address state","application 0 alternateContact address zipCode","income 0","per month",,,,,,"true","true",,"Yes","Studio,One Bedroom",,,,,,,"Decline to Respond"'; const mockedStream = new PassThrough(); exportResponse.pipe(mockedStream); @@ -254,7 +254,7 @@ describe('Testing application export service', () => { const headerRow = '"Application Id","Application Confirmation Code","Application Type","Application Submission Date","Primary Applicant First Name","Primary Applicant Middle Name","Primary Applicant Last Name","Primary Applicant Birth Day","Primary Applicant Birth Month","Primary Applicant Birth Year","Primary Applicant Email Address","Primary Applicant Phone Number","Primary Applicant Phone Type","Primary Applicant Additional Phone Number","Primary Applicant Preferred Contact Type","Primary Applicant Work in Region","Primary Applicant Street","Primary Applicant Street 2","Primary Applicant City","Primary Applicant State","Primary Applicant Zip Code","Primary Applicant Mailing Street","Primary Applicant Mailing Street 2","Primary Applicant Mailing City","Primary Applicant Mailing State","Primary Applicant Mailing Zip Code","Primary Applicant Work Street","Primary Applicant Work Street 2","Primary Applicant Work City","Primary Applicant Work State","Primary Applicant Work Zip Code","Alternate Contact First Name","Alternate Contact Last Name","Alternate Contact Type","Alternate Contact Agency","Alternate Contact Other Type","Alternate Contact Email Address","Alternate Contact Phone Number","Alternate Contact Street","Alternate Contact Street 2","Alternate Contact City","Alternate Contact State","Alternate Contact Zip Code","Income","Income Period","Accessibility Mobility","Accessibility Vision","Accessibility Hearing","Accessibility Hearing and Vision","Accessibility Other","Expecting Household Changes","Household Includes Student or Member Nearing 18","Vouchers or Subsidies","Requested Unit Types","Preference text 0","Preference text 0 - text - Address","Program text 1","Household Size","Household Member (1) First Name","Household Member (1) Middle Name","Household Member (1) Last Name","Household Member (1) Birth Day","Household Member (1) Birth Month","Household Member (1) Birth Year","Household Member (1) Relationship","Household Member (1) Same as Primary Applicant","Household Member (1) Work in Region","Household Member (1) Street","Household Member (1) Street 2","Household Member (1) City","Household Member (1) State","Household Member (1) Zip Code","Marked As Duplicate","Flagged As Duplicate"'; const firstApp = - '"application 0 firstName","application 0 middleName","application 0 lastName","application 0 birthDay","application 0 birthMonth","application 0 birthYear","application 0 emailaddress","application 0 phoneNumber","application 0 phoneNumberType","additionalPhoneNumber 0",,"yes","application 0 applicantAddress street","application 0 applicantAddress street2","application 0 applicantAddress city","application 0 applicantAddress state","application 0 applicantAddress zipCode","application 0 mailingAddress street","application 0 mailingAddress street2","application 0 mailingAddress city","application 0 mailingAddress state","application 0 mailingAddress zipCode","application 0 applicantWorkAddress street","application 0 applicantWorkAddress street2","application 0 applicantWorkAddress city","application 0 applicantWorkAddress state","application 0 applicantWorkAddress zipCode","application 0 alternateContact firstName","application 0 alternateContact lastName","application 0 alternateContact type","application 0 alternateContact agency","application 0 alternateContact otherType","application 0 alternatecontact emailaddress","application 0 alternateContact phoneNumber","application 0 alternateContact address street","application 0 alternateContact address street2","application 0 alternateContact address city","application 0 alternateContact address state","application 0 alternateContact address zipCode","income 0","per month",,,,,,"true","true","true","Studio,One Bedroom",,,,,,,,,,,,,,,,,,,,'; + '"application 0 firstName","application 0 middleName","application 0 lastName","application 0 birthDay","application 0 birthMonth","application 0 birthYear","application 0 emailaddress","application 0 phoneNumber","application 0 phoneNumberType","additionalPhoneNumber 0",,"yes","application 0 applicantAddress street","application 0 applicantAddress street2","application 0 applicantAddress city","application 0 applicantAddress state","application 0 applicantAddress zipCode","application 0 mailingAddress street","application 0 mailingAddress street2","application 0 mailingAddress city","application 0 mailingAddress state","application 0 mailingAddress zipCode","application 0 applicantWorkAddress street","application 0 applicantWorkAddress street2","application 0 applicantWorkAddress city","application 0 applicantWorkAddress state","application 0 applicantWorkAddress zipCode","application 0 alternateContact firstName","application 0 alternateContact lastName","application 0 alternateContact type","application 0 alternateContact agency","application 0 alternateContact otherType","application 0 alternatecontact emailaddress","application 0 alternateContact phoneNumber","application 0 alternateContact address street","application 0 alternateContact address street2","application 0 alternateContact address city","application 0 alternateContact address state","application 0 alternateContact address zipCode","income 0","per month",,,,,,"true","true","Yes","Studio,One Bedroom",,,,,,,,,,,,,,,,,,,,'; const mockedStream = new PassThrough(); exportResponse.pipe(mockedStream); @@ -565,7 +565,7 @@ describe('Testing application export service', () => { expect(objectData).toBe(undefined); expect(stringData).toEqual( - `"${id.toString()}","confirmationCode 1","electronic","12-05-2024 03:24:00AM PST","application 1 firstName","application 1 middleName","application 1 lastName","application 1 birthDay","application 1 birthMonth","application 1 birthYear","application 1 emailaddress","application 1 phoneNumber","application 1 phoneNumberType","additionalPhoneNumber 1",,"yes","application 1 applicantAddress street","application 1 applicantAddress street2","application 1 applicantAddress city","application 1 applicantAddress state","application 1 applicantAddress zipCode","application 1 mailingAddress street","application 1 mailingAddress street2","application 1 mailingAddress city","application 1 mailingAddress state","application 1 mailingAddress zipCode","application 1 applicantWorkAddress street","application 1 applicantWorkAddress street2","application 1 applicantWorkAddress city","application 1 applicantWorkAddress state","application 1 applicantWorkAddress zipCode","application 1 alternateContact firstName","application 1 alternateContact lastName","application 1 alternateContact type","application 1 alternateContact agency","application 1 alternateContact otherType","application 1 alternatecontact emailaddress","application 1 alternateContact phoneNumber","application 1 alternateContact address street","application 1 alternateContact address street2","application 1 alternateContact address city","application 1 alternateContact address state","application 1 alternateContact address zipCode","income 1","per month",,,,,,"true","true",,"true","Studio,One Bedroom","1","true","true"`, + `"${id.toString()}","confirmationCode 1","electronic","12-05-2024 03:24:00AM PST","application 1 firstName","application 1 middleName","application 1 lastName","application 1 birthDay","application 1 birthMonth","application 1 birthYear","application 1 emailaddress","application 1 phoneNumber","application 1 phoneNumberType","additionalPhoneNumber 1",,"yes","application 1 applicantAddress street","application 1 applicantAddress street2","application 1 applicantAddress city","application 1 applicantAddress state","application 1 applicantAddress zipCode","application 1 mailingAddress street","application 1 mailingAddress street2","application 1 mailingAddress city","application 1 mailingAddress state","application 1 mailingAddress zipCode","application 1 applicantWorkAddress street","application 1 applicantWorkAddress street2","application 1 applicantWorkAddress city","application 1 applicantWorkAddress state","application 1 applicantWorkAddress zipCode","application 1 alternateContact firstName","application 1 alternateContact lastName","application 1 alternateContact type","application 1 alternateContact agency","application 1 alternateContact otherType","application 1 alternatecontact emailaddress","application 1 alternateContact phoneNumber","application 1 alternateContact address street","application 1 alternateContact address street2","application 1 alternateContact address city","application 1 alternateContact address state","application 1 alternateContact address zipCode","income 1","per month",,,,,,"true","true",,"Yes","Studio,One Bedroom","1","true","true"`, ); }); @@ -662,7 +662,7 @@ describe('Testing application export service', () => { id: id, income: 'income 1', incomePeriod: 'per month', - incomeVouchers: 'true', + incomeVouchers: 'Yes', markedAsDuplicate: 'true', preferredUnitTypes: 'Studio,One Bedroom', reasonableAccommodations: '', diff --git a/api/test/unit/services/application.service.spec.ts b/api/test/unit/services/application.service.spec.ts index 2326bb418e5..22acc6a70dc 100644 --- a/api/test/unit/services/application.service.spec.ts +++ b/api/test/unit/services/application.service.spec.ts @@ -69,7 +69,7 @@ export const mockApplication = (options: { sendMailToMailingAddress: true, householdExpectingChanges: true, householdStudent: true, - incomeVouchers: true, + incomeVouchers: ['incomeVoucher'], income: `income ${options.position}`, incomePeriod: IncomePeriodEnum.perMonth, preferences: options.preferences || [], @@ -272,7 +272,7 @@ export const mockCreateApplicationData = ( householdSize: 2, householdStudent: false, housingStatus: 'example housing status', - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, @@ -360,7 +360,7 @@ const buildExpectedApplicationData = ({ sendMailToMailingAddress: true, householdExpectingChanges: false, householdStudent: false, - incomeVouchers: false, + incomeVouchers: [], income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index d216d32db3a..2811274647a 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -5815,6 +5815,150 @@ describe('Testing listing service', () => { ); expect(prisma.listingSnapshot.create).toHaveBeenCalled(); }); + + describe('scheduledPublishAt validation on create', () => { + const mockJurisdictionWithAutopublish = () => { + prisma.jurisdictions.findUnique = jest.fn().mockResolvedValue({ + id: 'jurisdiction-id', + featureFlags: [ + { name: FeatureFlagEnum.enableAutopublish, active: true }, + ], + }); + }; + + it('should throw when creating a listing with a past scheduledPublishAt', async () => { + mockJurisdictionWithAutopublish(); + + await expect( + service.create( + { + name: 'listing name', + depositMin: '5', + assets: [], + jurisdictions: { id: randomUUID() }, + status: ListingsStatusEnum.pending, + displayWaitlistSize: false, + unitsSummary: null, + listingEvents: [], + isVerified: true, + scheduledPublishAt: new Date('2020-01-01T00:00:00.000Z'), + } as ListingCreate, + user, + ), + ).rejects.toMatchObject({ + response: { message: ['scheduledPublishAt must be in the future'] }, + }); + }); + + it('should not throw when creating a listing with a future scheduledPublishAt', async () => { + jest + .useFakeTimers() + .setSystemTime(new Date('2020-01-01T00:00:00.000Z')); + mockJurisdictionWithAutopublish(); + prisma.listings.create = jest.fn().mockResolvedValue({ + id: 'example id', + name: 'example name', + }); + prisma.listingSnapshot.create = jest + .fn() + .mockResolvedValue({ id: 'snapshot-id' }); + + await expect( + service.create( + { + name: 'listing name', + depositMin: '5', + assets: [], + jurisdictions: { id: randomUUID() }, + status: ListingsStatusEnum.pending, + displayWaitlistSize: false, + unitsSummary: null, + listingEvents: [], + isVerified: true, + scheduledPublishAt: new Date('2030-01-01T00:00:00.000Z'), + } as ListingCreate, + user, + ), + ).resolves.not.toThrow(); + jest.useRealTimers(); + }); + + it('should ignore scheduledApplicationOpenAt when enableAutoOpenDate is disabled', async () => { + mockJurisdictionWithAutopublish(); + prisma.listings.create = jest.fn().mockResolvedValue({ + id: 'example id', + name: 'example name', + }); + prisma.listingSnapshot.create = jest + .fn() + .mockResolvedValue({ id: 'snapshot-id' }); + + await expect( + service.create( + { + name: 'listing name', + depositMin: '5', + assets: [], + jurisdictions: { id: randomUUID() }, + status: ListingsStatusEnum.pending, + displayWaitlistSize: false, + unitsSummary: null, + listingEvents: [], + isVerified: true, + scheduledApplicationOpenAt: new Date('2020-01-01T00:00:00.000Z'), + } as ListingCreate, + user, + ), + ).resolves.not.toThrow(); + }); + + it('should normalize and save scheduledApplicationOpenAt when enableAutoOpenDate is enabled without autopublish', async () => { + jest + .useFakeTimers() + .setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + prisma.jurisdictions.findUnique = jest.fn().mockResolvedValue({ + id: 'jurisdiction-id', + featureFlags: [ + { name: FeatureFlagEnum.enableAutoOpenDate, active: true }, + ], + listingApprovalPermissions: [], + publicUrl: '', + }); + prisma.listings.create = jest.fn().mockResolvedValue({ + id: 'example id', + name: 'example name', + scheduledApplicationOpenAt: new Date('2026-05-15T16:00:00.000Z'), + }); + prisma.listingSnapshot.create = jest + .fn() + .mockResolvedValue({ id: 'snapshot-id' }); + + await expect( + service.create( + { + name: 'listing name', + depositMin: '5', + assets: [], + jurisdictions: { id: randomUUID() }, + status: ListingsStatusEnum.pending, + displayWaitlistSize: false, + unitsSummary: null, + listingEvents: [], + isVerified: true, + scheduledApplicationOpenAt: new Date('2026-05-15T00:00:00.000Z'), + } as ListingCreate, + user, + ), + ).resolves.not.toThrow(); + + const createCall = (prisma.listings.create as jest.Mock).mock + .calls[0][0]; + expect(createCall.data.scheduledApplicationOpenAt.toISOString()).toBe( + '2026-05-15T16:00:00.000Z', + ); + jest.useRealTimers(); + }); + }); }); describe('Test delete endpoint', () => { @@ -6178,6 +6322,7 @@ describe('Testing listing service', () => { section8Acceptance: false, unitsAvailable: 0, isVerified: false, + publishedAt: null, }, where: { id: expect.anything(), @@ -6399,6 +6544,7 @@ describe('Testing listing service', () => { }, isVerified: false, section8Acceptance: false, + publishedAt: null, }, where: { id: expect.anything(), @@ -6969,6 +7115,7 @@ describe('Testing listing service', () => { name: 'example listing name', contentUpdatedAt: expect.anything(), closedAt: expect.anything(), + publishedAt: null, scheduledPublishAt: null, scheduledApplicationOpenAt: null, lastUpdatedByUser: { @@ -7069,6 +7216,198 @@ describe('Testing listing service', () => { multiselectQuestionServiceMock.retireMultiselectQuestions, ).toHaveBeenCalled(); }); + + describe('scheduledPublishAt validation bypasses on publishing or unpublishing', () => { + const pastDate = new Date('2020-01-01T00:00:00.000Z'); + const futureDate = new Date('2030-01-01T00:00:00.000Z'); + + beforeAll(() => { + jest + .useFakeTimers() + .setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const baseDto = ( + incomingStatus: ListingsStatusEnum, + scheduledPublishAt: Date, + ) => + ({ + id: 'listing-id', + name: 'listing name', + depositMin: '5', + assets: [], + jurisdictions: { id: 'jurisdiction-id' }, + status: incomingStatus, + displayWaitlistSize: false, + unitsSummary: null, + listingEvents: [], + lastUpdatedByUser: user, + scheduledPublishAt, + } as ListingUpdate); + + const mockStoredListing = (status: ListingsStatusEnum) => { + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: 'listing-id', + status, + listingMultiselectQuestions: [], + }); + }; + + const mockJurisdictionWithAutopublish = () => { + prisma.jurisdictions.findUnique = jest.fn().mockResolvedValue({ + id: 'jurisdiction-id', + featureFlags: [ + { name: FeatureFlagEnum.enableAutopublish, active: true }, + ], + listingApprovalPermissions: [], + }); + }; + + const mockListingUpdate = (resultStatus: ListingsStatusEnum) => { + prisma.listings.update = jest.fn().mockResolvedValue({ + id: 'listing-id', + status: resultStatus, + listingMultiselectQuestions: [], + units: [], + }); + prisma.listingEvents.findMany = jest.fn().mockResolvedValue([]); + prisma.$transaction = jest.fn().mockResolvedValue([ + { + id: 'listing-id', + listingMultiselectQuestions: [], + units: [], + }, + ]); + }; + + it('should throw when updating a non-active listing with no status change and a past scheduledPublishAt', async () => { + mockStoredListing(ListingsStatusEnum.pending); + mockJurisdictionWithAutopublish(); + + await expect( + service.update(baseDto(ListingsStatusEnum.pending, pastDate), user), + ).rejects.toMatchObject({ + response: { message: ['scheduledPublishAt must be in the future'] }, + }); + }); + + it('should throw when transitioning from non-active to pendingReview with a past scheduledPublishAt', async () => { + mockStoredListing(ListingsStatusEnum.pending); + mockJurisdictionWithAutopublish(); + + await expect( + service.update( + baseDto(ListingsStatusEnum.pendingReview, pastDate), + user, + ), + ).rejects.toMatchObject({ + response: { message: ['scheduledPublishAt must be in the future'] }, + }); + }); + + it('should not throw when publishing (status → active) with a past scheduledPublishAt', async () => { + mockStoredListing(ListingsStatusEnum.pending); + mockJurisdictionWithAutopublish(); + mockListingUpdate(ListingsStatusEnum.active); + jest + .spyOn(service, 'getUserEmailInfo') + .mockResolvedValueOnce({ emails: [] }); + jest + .spyOn(service, 'sendListingPublishNotification') + .mockResolvedValueOnce(undefined); + + await expect( + service.update(baseDto(ListingsStatusEnum.active, pastDate), user), + ).resolves.not.toThrow(); + }); + + it('should not throw when unpublishing (active → pending) with a past scheduledPublishAt', async () => { + mockStoredListing(ListingsStatusEnum.active); + mockJurisdictionWithAutopublish(); + mockListingUpdate(ListingsStatusEnum.pending); + + await expect( + service.update(baseDto(ListingsStatusEnum.pending, pastDate), user), + ).resolves.not.toThrow(); + }); + + it('should not throw when an active listing stays active with a past scheduledPublishAt', async () => { + mockStoredListing(ListingsStatusEnum.active); + mockJurisdictionWithAutopublish(); + mockListingUpdate(ListingsStatusEnum.active); + jest + .spyOn(service, 'sendListingPublishNotification') + .mockResolvedValueOnce(undefined); + + await expect( + service.update(baseDto(ListingsStatusEnum.active, pastDate), user), + ).resolves.not.toThrow(); + }); + + it('should not throw when updating a non-active listing with a future scheduledPublishAt', async () => { + mockStoredListing(ListingsStatusEnum.pending); + mockJurisdictionWithAutopublish(); + mockListingUpdate(ListingsStatusEnum.pending); + + await expect( + service.update(baseDto(ListingsStatusEnum.pending, futureDate), user), + ).resolves.not.toThrow(); + }); + + it('should ignore scheduledApplicationOpenAt on update when enableAutoOpenDate is disabled', async () => { + mockStoredListing(ListingsStatusEnum.pending); + mockJurisdictionWithAutopublish(); + mockListingUpdate(ListingsStatusEnum.pending); + + await expect( + service.update( + { + ...baseDto(ListingsStatusEnum.pending, futureDate), + scheduledApplicationOpenAt: pastDate, + }, + user, + ), + ).resolves.not.toThrow(); + }); + + it('should normalize and validate scheduledApplicationOpenAt when enableAutoOpenDate is enabled without autopublish', async () => { + jest + .useFakeTimers() + .setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + prisma.jurisdictions.findUnique = jest.fn().mockResolvedValue({ + id: 'jurisdiction-id', + featureFlags: [ + { name: FeatureFlagEnum.enableAutoOpenDate, active: true }, + ], + listingApprovalPermissions: [], + }); + mockStoredListing(ListingsStatusEnum.pending); + mockListingUpdate(ListingsStatusEnum.pending); + + const scheduledApplicationOpenAt = new Date('2026-05-15T14:30:00.000Z'); + + await expect( + service.update( + { + ...baseDto(ListingsStatusEnum.pending, futureDate), + scheduledApplicationOpenAt, + }, + user, + ), + ).resolves.not.toThrow(); + + const updateMock = prisma.listings.update as jest.Mock; + const updateCall = updateMock.mock.calls[0][0]; + expect(updateCall.data.scheduledApplicationOpenAt.toISOString()).toBe( + '2026-05-15T16:00:00.000Z', + ); + jest.useRealTimers(); + }); + }); }); describe('Test listingApprovalNotify endpoint', () => { @@ -8015,5 +8354,76 @@ describe('Testing listing service', () => { service.normalizeScheduledApplicationOpenAt(nonMidnight); expect(normalized.toISOString()).toBe('2026-05-15T16:00:00.000Z'); }); + + it('should normalize to 9:00 AM in the configured app timezone', () => { + const previousTimeZone = process.env.TIME_ZONE; + try { + process.env.TIME_ZONE = 'America/New_York'; + const utcMidnight = new Date('2026-01-15T00:00:00.000Z'); + const normalized = + service.normalizeScheduledApplicationOpenAt(utcMidnight); + expect(normalized.toISOString()).toBe('2026-01-15T14:00:00.000Z'); + } finally { + process.env.TIME_ZONE = previousTimeZone; + } + }); + }); + + describe('resolvePublishedAt', () => { + const storedDate = new Date('2025-01-01T00:00:00.000Z'); + + it('pending → active: returns a new Date (first publish)', () => { + const result = ListingService['resolvePublishedAt']( + ListingsStatusEnum.active, + ListingsStatusEnum.pending, + null, + ); + expect(result).toBeInstanceOf(Date); + }); + + it('active → active: returns stored value (no re-stamp)', () => { + const result = ListingService['resolvePublishedAt']( + ListingsStatusEnum.active, + ListingsStatusEnum.active, + storedDate, + ); + expect(result).toBe(storedDate); + }); + + it('active → pending: returns null (clears publishedAt)', () => { + const result = ListingService['resolvePublishedAt']( + ListingsStatusEnum.pending, + ListingsStatusEnum.active, + storedDate, + ); + expect(result).toBeNull(); + }); + + it('pending → pending: returns null (was never published)', () => { + const result = ListingService['resolvePublishedAt']( + ListingsStatusEnum.pending, + ListingsStatusEnum.pending, + null, + ); + expect(result).toBeNull(); + }); + + it('active → closed: returns stored value', () => { + const result = ListingService['resolvePublishedAt']( + ListingsStatusEnum.closed, + ListingsStatusEnum.active, + storedDate, + ); + expect(result).toBe(storedDate); + }); + + it('scheduled → active: returns a new Date (scheduled auto-publish path)', () => { + const result = ListingService['resolvePublishedAt']( + ListingsStatusEnum.active, + ListingsStatusEnum.scheduled, + null, + ); + expect(result).toBeInstanceOf(Date); + }); }); }); diff --git a/api/test/unit/services/snapshot-create.service.spec.ts b/api/test/unit/services/snapshot-create.service.spec.ts index c9c2b67258f..915b44a314b 100644 --- a/api/test/unit/services/snapshot-create.service.spec.ts +++ b/api/test/unit/services/snapshot-create.service.spec.ts @@ -3303,7 +3303,7 @@ describe('Testing snapshot create service', () => { housingStatus: 'example housingStatus', income: 'example income', incomePeriod: IncomePeriodEnum.perMonth, - incomeVouchers: true, + incomeVouchers: ['incomeVoucher'], isNewest: true, language: LanguagesEnum.bn, manualLotteryPositionNumber: 34, @@ -3637,7 +3637,7 @@ describe('Testing snapshot create service', () => { housingStatus: 'example housingStatus', income: 'example income', incomePeriod: IncomePeriodEnum.perMonth, - incomeVouchers: true, + incomeVouchers: ['incomeVoucher'], isNewest: true, language: LanguagesEnum.bn, manualLotteryPositionNumber: 34, @@ -3682,7 +3682,7 @@ describe('Testing snapshot create service', () => { housingStatus: 'example housingStatus', income: 'example income', incomePeriod: IncomePeriodEnum.perMonth, - incomeVouchers: true, + incomeVouchers: ['incomeVoucher'], isNewest: true, language: LanguagesEnum.bn, manualLotteryPositionNumber: 34, @@ -4264,7 +4264,7 @@ describe('Testing snapshot create service', () => { housingStatus: 'example housingStatus', income: 'example income', incomePeriod: IncomePeriodEnum.perMonth, - incomeVouchers: true, + incomeVouchers: ['incomeVoucher'], isNewest: true, language: LanguagesEnum.bn, manualLotteryPositionNumber: 34, diff --git a/docs/feature-flags.md b/docs/feature-flags.md index a2159af8aa3..cf872d39781 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -39,6 +39,7 @@ The following are all of the feature flags currently available in the Bloom plat | [enableAccessibilityFeatures](./feature-flags/enableAccessibilityFeatures.md) | When true, the 'accessibility features' section is displayed in listing creation/edit and the public listing view | | [enableAdditionalResources](./feature-flags/enableAdditionalResources.md) | When true, the 'learn more' section is displayed on the home page | | [enableApplicationStatus](./feature-flags/enableApplicationStatus.md) | When true, the application status and notifications feature is enabled on public and partners | +| [enableAutoOpenDate](./feature-flags/enableAutoOpenDate.md) | When true, partners can set an optional scheduled listing applications open date | | [enableAutopublish](./feature-flags/enableAutopublish.md) | When true, partners can set an optional scheduled listing publish date | | [enableCompanyWebsite](./feature-flags/enableCompanyWebsite.md) | When true, allows partners to add company website information | | [enableConfigurableRegions](./feature-flags/enableConfigurableRegions.md) | When true, allows for configurable regions per jurisdiction enabled on partners and public | @@ -68,6 +69,7 @@ The following are all of the feature flags currently available in the Bloom plat | [enableMarketingFlyer](./feature-flags/enableMarketingFlyer.md) | When true, the 'marketing flyer' sub-section is displayed in listing creation/edit and the public listing view | | [enableMarketingStatus](./feature-flags/enableMarketingStatus.md) | When true, the 'marketing status' sub-section is displayed in listing creation/edit and the public listing view | | [enableMarketingStatusMonths](./feature-flags/enableMarketingStatusMonths.md) | When true, the 'marketing status' sub-section uses months instead of seasons (functions only if enableMarketingStatus is also true) | +| [enableMultiselectVoucherQuestion](./feature-flags/enableMultiselectVoucherQuestion.md) | When true, the vouchers question on the application form becomes a multi-select checkbox experience with Section 8, rental assistance, and none of the above as options | | [enableNeighborhoodAmenities](./feature-flags/enableNeighborhoodAmenities.md) | When true, the 'neighborhood amenities' section is displayed in listing creation/edit and the public listing view | | [enableNeighborhoodAmenitiesDropdown](./feature-flags/enableNeighborhoodAmenitiesDropdown.md) | When true, neighborhood amenities inputs render as dropdowns with distance options instead of textareas | | [enableNonRegulatedListings](./feature-flags/enableNonRegulatedListings.md) | When true, non-regulated listings are displayed in listing creation/edit and public listing view | diff --git a/docs/feature-flags/enableAutoOpenDate.md b/docs/feature-flags/enableAutoOpenDate.md new file mode 100644 index 00000000000..399f492906a --- /dev/null +++ b/docs/feature-flags/enableAutoOpenDate.md @@ -0,0 +1,13 @@ +# enableAutoOpenDate + +## Name + +`enableAutoOpenDate` + +## Description + +When true, partners can set an optional scheduled listing applications open date + +## Additional Information + +## Images \ No newline at end of file diff --git a/docs/feature-flags/enableMultiselectVoucherQuestion.md b/docs/feature-flags/enableMultiselectVoucherQuestion.md new file mode 100644 index 00000000000..5b3da3643b8 --- /dev/null +++ b/docs/feature-flags/enableMultiselectVoucherQuestion.md @@ -0,0 +1,13 @@ +# enableMultiselectVoucherQuestion + +## Name + +`enableMultiselectVoucherQuestion` + +## Description + +When true, the vouchers question on the application form becomes a multi-select checkbox experience with Section 8, rental assistance, and none of the above as options + +## Additional Information + +## Images diff --git a/shared-helpers/__tests__/testHelpers.ts b/shared-helpers/__tests__/testHelpers.ts index bd544f262ce..76c32f4608e 100644 --- a/shared-helpers/__tests__/testHelpers.ts +++ b/shared-helpers/__tests__/testHelpers.ts @@ -196,7 +196,7 @@ export const application: Application = { householdExpectingChanges: true, householdStudent: false, reasonableAccommodations: "Wheelchair-accessible unit entrance", - incomeVouchers: true, + incomeVouchers: ["incomeVoucher"], income: "40000.00", incomePeriod: IncomePeriodEnum.perYear, status: ApplicationStatusEnum.submitted, diff --git a/shared-helpers/src/locales/ar.json b/shared-helpers/src/locales/ar.json index 43d40ccddc9..66ed762af51 100644 --- a/shared-helpers/src/locales/ar.json +++ b/shared-helpers/src/locales/ar.json @@ -259,12 +259,17 @@ "application.financial.income.validationError.reason.low": "دخل أسرتك منخفض للغاية.", "application.financial.vouchers.housingVouchers.strong": "قسائم الإسكان", "application.financial.vouchers.housingVouchers.text": "مثل القسم 8", + "application.financial.vouchers.issuedVouchers": "قسيمة اختيار السكن بموجب القسم 8", "application.financial.vouchers.legend": "قسائم الإسكان والدخل غير الخاضع للضريبة أو إعانات الإيجار", + "application.financial.vouchers.none": "لا شيء مما سبق", "application.financial.vouchers.nonTaxableIncome.strong": "الدخل غير الخاضع للضريبة", "application.financial.vouchers.nonTaxableIncome.text": "مثل SSI أو SSDI أو مدفوعات إعالة الطفل أو مزايا تعويض العمال", + "application.financial.vouchers.rentalAssistance": "مساعدة الإيجار من مصادر أخرى", "application.financial.vouchers.rentalSubsidies.strong": "إعانات الإيجار", "application.financial.vouchers.rentalSubsidies.text": "مثل VASH و HSA و HOPWA والجمعيات الخيرية الكاثوليكية ومؤسسة الإيدز ، إلخ.", + "application.financial.vouchers.subtitleAlt": "تشمل المساعدة في الإيجار من مصادر أخرى الدعم المقدم من برامج مثل مؤسسة كاريتاس الكاثوليكية أو مؤسسة الإيدز.", "application.financial.vouchers.title": "هل تتلقى أنت أو أي شخص في هذا التطبيق أيًا مما يلي؟", + "application.financial.vouchers.titleAlt": "هل تتلقى أنت أو أي شخص في منزلك قسائم صادرة عن برنامج الإسكان المدعوم (Section 8) أو هيئة الإسكان، بما في ذلك قسائم الإسكان المدعوم لشؤون المحاربين القدامى (VASH)، أو مساعدات إيجارية من مصادر أخرى؟", "application.form.general.advocateWarning": "أنت تقدم الطلب نيابة عن عميل؛ يرجى إدخال معلومات عميلك، وليس معلوماتك الشخصية.", "application.form.general.saveAndFinishLater": "احفظها وأكملها لاحقًا", "application.form.general.saveAndReturn": "حفظ والعودة للمراجعة", @@ -1445,7 +1450,7 @@ "t.tryRemovingFilters": "حاول إزالة بعض المرشحات.", "t.unit": "وحدة", "t.unitAmenities": "وسائل الراحة في الوحدة", - "t.unitFeatures": "مميزات الوحدة", + "t.unitFeatures": "ميزات الوحدة ونوع إمكانية الوصول", "t.units": "الوحدات", "t.unitType": "نوع الوحدة", "t.uploadFile": "تحميل الملف", diff --git a/shared-helpers/src/locales/bn.json b/shared-helpers/src/locales/bn.json index ad9b4696e04..537f13bd548 100644 --- a/shared-helpers/src/locales/bn.json +++ b/shared-helpers/src/locales/bn.json @@ -259,12 +259,17 @@ "application.financial.income.validationError.reason.low": "আপনার পরিবারের আয় খুবই কম।", "application.financial.vouchers.housingVouchers.strong": "হাউজিং ভাউচার", "application.financial.vouchers.housingVouchers.text": "অধ্যায় 8 মত", + "application.financial.vouchers.issuedVouchers": "সেকশন ৮ হাউজিং চয়েস ভাউচার", "application.financial.vouchers.legend": "হাউজিং ভাউচার, ননটেক্সেবল আয় বা ভাড়া ভর্তুকি", + "application.financial.vouchers.none": "উপরের কোনোটিই নয়", "application.financial.vouchers.nonTaxableIncome.strong": "অ-করযোগ্য আয়", "application.financial.vouchers.nonTaxableIncome.text": "যেমন SSI, SSDI, চাইল্ড সাপোর্ট পেমেন্ট, অথবা শ্রমিকের ক্ষতিপূরণ সুবিধা", + "application.financial.vouchers.rentalAssistance": "অন্যান্য উৎস থেকে ভাড়া সহায়তা", "application.financial.vouchers.rentalSubsidies.strong": "ভাড়া ভর্তুকি", "application.financial.vouchers.rentalSubsidies.text": "যেমন VASH, HSA, HOPWA, Catholic Charities, AIDS Foundation, ইত্যাদি।", + "application.financial.vouchers.subtitleAlt": "অন্যান্য উৎস থেকে প্রাপ্ত ভাড়া সহায়তার মধ্যে ক্যাথলিক চ্যারিটিজ বা এইডস ফাউন্ডেশনের মতো কর্মসূচি থেকে প্রাপ্ত সহায়তা অন্তর্ভুক্ত।", "application.financial.vouchers.title": "আপনি বা এই অ্যাপ্লিকেশনে কেউ নিচের কোনটি পান?", + "application.financial.vouchers.titleAlt": "আপনি বা আপনার পরিবারের কেউ কি সেকশন ৮ বা হাউজিং অথরিটি কর্তৃক ইস্যুকৃত ভাউচার, যার মধ্যে ভেটেরান্স অ্যাফেয়ার্স সাপোর্টিভ হাউজিং (VASH) ভাউচার অন্তর্ভুক্ত, অথবা অন্য কোনো উৎস থেকে ভাড়া সহায়তা পেয়ে থাকেন?", "application.form.general.advocateWarning": "আপনি একজন ক্লায়েন্টের পক্ষে আবেদন করছেন; অনুগ্রহ করে আপনার ক্লায়েন্টের তথ্য লিখুন, নিজের তথ্য নয়।", "application.form.general.saveAndFinishLater": "সংরক্ষণ করুন এবং পরে শেষ করুন", "application.form.general.saveAndReturn": "সংরক্ষণ করুন এবং পর্যালোচনায় ফিরে আসুন", @@ -1445,7 +1450,7 @@ "t.tryRemovingFilters": "আপনার কিছু ফিল্টার সরিয়ে ফেলার চেষ্টা করুন।", "t.unit": "ইউনিট", "t.unitAmenities": "ইউনিটের সুযোগ-সুবিধা", - "t.unitFeatures": "ইউনিট বৈশিষ্ট্য", + "t.unitFeatures": "ইউনিটের বৈশিষ্ট্য এবং প্রবেশযোগ্যতার ধরণ", "t.units": "ইউনিট", "t.unitType": "ইউনিটের ধরণ", "t.uploadFile": "ফাইল আপলোড করুন", diff --git a/shared-helpers/src/locales/es.json b/shared-helpers/src/locales/es.json index 7c3021c54ab..1c3702cefc6 100644 --- a/shared-helpers/src/locales/es.json +++ b/shared-helpers/src/locales/es.json @@ -259,12 +259,17 @@ "application.financial.income.validationError.reason.low": "Los ingresos de su hogar son demasiado bajos.", "application.financial.vouchers.housingVouchers.strong": "Cupones de viviendas", "application.financial.vouchers.housingVouchers.text": "como Section 8", + "application.financial.vouchers.issuedVouchers": "Vale de elección de vivienda de la Sección 8", "application.financial.vouchers.legend": "Recibos de vivienda, ingresos deducibles o subsidios de alquiler", + "application.financial.vouchers.none": "Ninguna de las anteriores", "application.financial.vouchers.nonTaxableIncome.strong": "Ingresos no gravables de impuestos", "application.financial.vouchers.nonTaxableIncome.text": "como SSI, SSDI, pagos de manutención infantil o beneficios de compensaciones del trabajador", + "application.financial.vouchers.rentalAssistance": "Ayuda para el alquiler procedente de otras fuentes", "application.financial.vouchers.rentalSubsidies.strong": "Subsidios en el alquiler", "application.financial.vouchers.rentalSubsidies.text": "como VASH, HSA, HOPWA, Catholic Charities, AIDS Foundation, etc.", + "application.financial.vouchers.subtitleAlt": "La ayuda para el alquiler procedente de otras fuentes incluye el apoyo de programas como Catholic Charities o la Fundación contra el SIDA.", "application.financial.vouchers.title": "¿Recibe usted o alguna otra persona de esta solicitud alguno de los siguientes?", + "application.financial.vouchers.titleAlt": "¿Usted o algún miembro de su hogar recibe vales del Programa de la Sección 8 o vales emitidos por la Autoridad de Vivienda, incluidos los vales de Vivienda de Apoyo para Veteranos (VASH), o asistencia para el alquiler de otras fuentes?", "application.form.general.advocateWarning": "Usted está presentando la solicitud en nombre de un cliente; ingrese la información de su cliente, no la suya propia.", "application.form.general.saveAndFinishLater": "Guardar y terminar más tarde", "application.form.general.saveAndReturn": "Guardar y regresar a revisión", @@ -1445,7 +1450,7 @@ "t.tryRemovingFilters": "Intente eliminar algunos de sus filtros.", "t.unit": "vivienda", "t.unitAmenities": "Servicios en la vivienda", - "t.unitFeatures": "Características de la vivienda", + "t.unitFeatures": "Características de la unidad y tipo de accesibilidad", "t.units": "viviendas", "t.unitType": "Tipo de vivienda", "t.uploadFile": "Subir archivo", diff --git a/shared-helpers/src/locales/fa.json b/shared-helpers/src/locales/fa.json index 74a1666fea9..6882d5d9762 100644 --- a/shared-helpers/src/locales/fa.json +++ b/shared-helpers/src/locales/fa.json @@ -259,12 +259,17 @@ "application.financial.income.validationError.reason.low": "درآمد خانوار شما خیلی کم است.", "application.financial.vouchers.housingVouchers.strong": "کوپن‌های مسکن", "application.financial.vouchers.housingVouchers.text": "مثل بخش ۸", + "application.financial.vouchers.issuedVouchers": "کوپن انتخاب مسکن بخش ۸", "application.financial.vouchers.legend": "کوپن‌های مسکن، درآمد معاف از مالیات یا یارانه‌های اجاره", + "application.financial.vouchers.none": "هیچ یک از موارد فوق", "application.financial.vouchers.nonTaxableIncome.strong": "درآمد غیر مشمول مالیات", "application.financial.vouchers.nonTaxableIncome.text": "مانند SSI، SSDI، پرداخت‌های حمایت از کودک یا مزایای جبران خسارت کارگران", + "application.financial.vouchers.rentalAssistance": "کمک هزینه اجاره از منابع دیگر", "application.financial.vouchers.rentalSubsidies.strong": "یارانه اجاره", "application.financial.vouchers.rentalSubsidies.text": "مانند VASH، HSA، HOPWA، خیریه‌های کاتولیک، بنیاد ایدز و غیره.", + "application.financial.vouchers.subtitleAlt": "کمک هزینه اجاره از منابع دیگر شامل حمایت از برنامه‌هایی مانند خیریه‌های کاتولیک یا بنیاد ایدز می‌شود.", "application.financial.vouchers.title": "آیا شما یا هر یک از اعضای این درخواست، موارد زیر را دریافت می‌کنید؟", + "application.financial.vouchers.titleAlt": "آیا شما یا هر یک از اعضای خانواده‌تان کوپن‌های بخش ۸ یا کوپن‌های صادر شده توسط اداره مسکن، از جمله کوپن‌های مسکن حمایتی امور ایثارگران (VASH)، یا کمک هزینه اجاره از منابع دیگر دریافت می‌کنید؟", "application.form.general.advocateWarning": "شما از طرف یک موکل درخواست می‌دهید؛ لطفاً اطلاعات موکل خود را وارد کنید، نه اطلاعات خودتان را .", "application.form.general.saveAndFinishLater": "ذخیره کنید و بعداً تمام کنید", "application.form.general.saveAndReturn": "ذخیره و بازگشت به بررسی", @@ -1449,7 +1454,7 @@ "t.tryRemovingFilters": "سعی کنید برخی از فیلترهای خود را حذف کنید.", "t.unit": "واحد", "t.unitAmenities": "امکانات رفاهی واحد", - "t.unitFeatures": "ویژگی‌های واحد", + "t.unitFeatures": "ویژگی‌های واحد و نوع دسترسی", "t.units": "واحدها", "t.unitType": "نوع واحد", "t.uploadFile": "آپلود فایل", diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index 9d8b93ecee3..93e03ee5716 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -259,11 +259,16 @@ "application.financial.income.validationError.reason.low": "Your household income is too low.", "application.financial.vouchers.housingVouchers.strong": "Housing vouchers", "application.financial.vouchers.housingVouchers.text": "like Section 8", + "application.financial.vouchers.issuedVouchers": "Section 8 Housing Choice Voucher", "application.financial.vouchers.legend": "Housing vouchers, nontaxable income or rental subsidies", + "application.financial.vouchers.none": "None of the above", "application.financial.vouchers.nonTaxableIncome.strong": "Non-taxable income", "application.financial.vouchers.nonTaxableIncome.text": "like SSI, SSDI, child support payments, or worker's compensation benefits", + "application.financial.vouchers.rentalAssistance": "Rental assistance from other sources", "application.financial.vouchers.rentalSubsidies.strong": "Rental subsidies", "application.financial.vouchers.rentalSubsidies.text": "like VASH, HSA, HOPWA, Catholic Charities, AIDS Foundation, etc.", + "application.financial.vouchers.subtitleAlt": "Rental assistance from other sources includes support from programs such as Catholic Charities or the AIDS Foundation.", + "application.financial.vouchers.titleAlt": "Do you or anyone in your household receive Section 8 or Housing Authority issued vouchers, including Veterans Affairs Supportive Housing (VASH) vouchers, or rental assistance from other sources?", "application.financial.vouchers.title": "Do you or anyone on this application receive any of the following?", "application.form.general.advocateWarning": "You are applying on behalf of a client; please enter your client’s information, not your own.", "application.form.general.saveAndFinishLater": "Save and finish later", @@ -1441,7 +1446,7 @@ "t.tryRemovingFilters": "Try removing some of your filters.", "t.unit": "unit", "t.unitAmenities": "Unit amenities", - "t.unitFeatures": "Unit features", + "t.unitFeatures": "Unit features and accessibility type", "t.units": "units", "t.unitType": "Unit type", "t.uploadFile": "Upload file", diff --git a/shared-helpers/src/locales/hy.json b/shared-helpers/src/locales/hy.json index e39b7b58b30..339947211f2 100644 --- a/shared-helpers/src/locales/hy.json +++ b/shared-helpers/src/locales/hy.json @@ -259,12 +259,17 @@ "application.financial.income.validationError.reason.low": "Ձեր ընտանիքի եկամուտը չափազանց ցածր է։", "application.financial.vouchers.housingVouchers.strong": "Բնակարանային վաուչերներ", "application.financial.vouchers.housingVouchers.text": "ինչպես 8-րդ բաժինը", + "application.financial.vouchers.issuedVouchers": "Բաժին 8՝ Բնակարանային ընտրության վկայական", "application.financial.vouchers.legend": "Բնակարանային վաուչերներ, չհարկվող եկամուտ կամ վարձակալության սուբսիդիաներ", + "application.financial.vouchers.none": "Վերը նշվածներից ոչ մեկը", "application.financial.vouchers.nonTaxableIncome.strong": "Ոչ հարկվող եկամուտ", "application.financial.vouchers.nonTaxableIncome.text": "ինչպիսիք են՝ SSI-ը, SSDI-ը, երեխայի խնամքի վճարումները կամ աշխատողի փոխհատուցման նպաստները", + "application.financial.vouchers.rentalAssistance": "Վարձակալության օգնություն այլ աղբյուրներից", "application.financial.vouchers.rentalSubsidies.strong": "Վարձավճարի սուբսիդիաներ", "application.financial.vouchers.rentalSubsidies.text": "ինչպիսիք են VASH-ը, HSA-ն, HOPWA-ն, կաթոլիկ բարեգործական կազմակերպությունները, AIDS-ի հիմնադրամը և այլն:", + "application.financial.vouchers.subtitleAlt": "Այլ աղբյուրներից վարձակալության օգնությունը ներառում է այնպիսի ծրագրերի աջակցություն, ինչպիսիք են Կաթոլիկ բարեգործությունները կամ ՁԻԱՀ-ի հիմնադրամը:", "application.financial.vouchers.title": "Դուք կամ այս դիմումում նշված որևէ մեկը ստանո՞ւմ եք հետևյալներից որևէ մեկը։", + "application.financial.vouchers.titleAlt": "Դուք կամ ձեր տնային տնտեսության որևէ անդամ ստանո՞ւմ եք 8-րդ բաժնի կամ Բնակարանային վարչության կողմից տրամադրված վաուչերներ, այդ թվում՝ Վետերանների հարցերով աջակցող բնակարանային (VASH) վաուչերներ, կամ վարձակալության օգնություն այլ աղբյուրներից:", "application.form.general.advocateWarning": "Դուք դիմում եք հաճախորդի անունից։ Խնդրում ենք մուտքագրել ձեր հաճախորդի տվյալները, այլ ոչ թե ձեր սեփականը ։", "application.form.general.saveAndFinishLater": "Պահպանել և ավարտել ավելի ուշ", "application.form.general.saveAndReturn": "Պահպանել և վերադառնալ ակնարկին", @@ -1449,7 +1454,7 @@ "t.tryRemovingFilters": "Փորձեք հեռացնել ձեր որոշ ֆիլտրեր։", "t.unit": "միավոր", "t.unitAmenities": "Բնակարանի հարմարություններ", - "t.unitFeatures": "Միավորի առանձնահատկությունները", + "t.unitFeatures": "Միավորի առանձնահատկությունները և մատչելիության տեսակը", "t.units": "միավորներ", "t.unitType": "Միավորի տեսակը", "t.uploadFile": "Ֆայլի վերբեռնում", diff --git a/shared-helpers/src/locales/ko.json b/shared-helpers/src/locales/ko.json index f021f9007e7..a7dd78056ad 100644 --- a/shared-helpers/src/locales/ko.json +++ b/shared-helpers/src/locales/ko.json @@ -259,12 +259,17 @@ "application.financial.income.validationError.reason.low": "귀하의 가구 소득이 너무 낮습니다.", "application.financial.vouchers.housingVouchers.strong": "주택 바우처", "application.financial.vouchers.housingVouchers.text": "섹션 8처럼", + "application.financial.vouchers.issuedVouchers": "섹션 8 주택 선택 바우처", "application.financial.vouchers.legend": "주택 바우처, 비과세 소득 또는 임대료 보조금", + "application.financial.vouchers.none": "위의 사항 중 어느 것도 해당되지 않습니다.", "application.financial.vouchers.nonTaxableIncome.strong": "비과세 소득", "application.financial.vouchers.nonTaxableIncome.text": "SSI, SSDI, 자녀 양육비 또는 산재 보상금과 같은 것들", + "application.financial.vouchers.rentalAssistance": "다른 출처로부터의 임대료 지원", "application.financial.vouchers.rentalSubsidies.strong": "임대료 보조금", "application.financial.vouchers.rentalSubsidies.text": "VASH, HSA, HOPWA, 가톨릭 자선단체, 에이즈 재단 등과 같은 단체들.", + "application.financial.vouchers.subtitleAlt": "다른 출처에서 제공되는 임대료 지원에는 가톨릭 자선 단체나 에이즈 재단과 같은 프로그램의 지원이 포함됩니다.", "application.financial.vouchers.title": "귀하 또는 이 신청서에 포함된 다른 사람이 다음 중 어떤 것을 받고 있습니까?", + "application.financial.vouchers.titleAlt": "귀하 또는 귀하의 가구 구성원 중 누군가가 섹션 8 바우처 또는 주택 공사에서 발행한 바우처(재향군인 지원 주택(VASH) 바우처 포함) 또는 기타 출처로부터 임대료 지원을 받고 있습니까?", "application.form.general.advocateWarning": "귀하는 고객을 대신하여 신청하는 것이므로, 귀하의 정보가 아닌 고객의 정보를 입력하십시오.", "application.form.general.saveAndFinishLater": "저장하고 나중에 완료하세요", "application.form.general.saveAndReturn": "저장하고 검토 화면으로 돌아가기", @@ -1449,7 +1454,7 @@ "t.tryRemovingFilters": "필터를 몇 개 제거해 보세요.", "t.unit": "단위", "t.unitAmenities": "유닛 편의시설", - "t.unitFeatures": "유닛 특징", + "t.unitFeatures": "유닛 특징 및 접근성 유형", "t.units": "단위", "t.unitType": "단위 유형", "t.uploadFile": "파일 업로드", diff --git a/shared-helpers/src/locales/tl.json b/shared-helpers/src/locales/tl.json index 271f059ee27..6d5fcadba28 100644 --- a/shared-helpers/src/locales/tl.json +++ b/shared-helpers/src/locales/tl.json @@ -259,12 +259,17 @@ "application.financial.income.validationError.reason.low": "Napakababa ng kita ng iyong sambahayan.", "application.financial.vouchers.housingVouchers.strong": "Mga housing voucher", "application.financial.vouchers.housingVouchers.text": "tulad ng Seksyon 8", + "application.financial.vouchers.issuedVouchers": "Seksyon 8 na Kupon ng Pagpili ng Pabahay", "application.financial.vouchers.legend": "Mga voucher sa pabahay, kita na hindi kinakaltasan ng buwis o mga subsidiya sa pagrenta", + "application.financial.vouchers.none": "Wala sa mga nabanggit", "application.financial.vouchers.nonTaxableIncome.strong": "Kita na hindi kinakaltasan ng buwis", "application.financial.vouchers.nonTaxableIncome.text": "tulad ng SSI, SSDI, mga pagbabayad sa sustento sa anak, o kabayarang benepisyo ng manggagawa", + "application.financial.vouchers.rentalAssistance": "Tulong sa pag-upa mula sa iba pang mga mapagkukunan", "application.financial.vouchers.rentalSubsidies.strong": "Mga subsidiya sa pagrent", "application.financial.vouchers.rentalSubsidies.text": "tulad ng VASH, HSA, HOPWA, Catholic Charities, AIDS Foundation, atbp.", + "application.financial.vouchers.subtitleAlt": "Kasama sa tulong sa pag-upa mula sa ibang mga mapagkukunan ang suporta mula sa mga programang tulad ng Catholic Charities o ng AIDS Foundation.", "application.financial.vouchers.title": "Ikaw ba o sinuman sa application na ito ay tumatanggap ng alinman sa mga sumusunod?", + "application.financial.vouchers.titleAlt": "Ikaw ba o sinuman sa iyong sambahayan ay tumatanggap ng mga voucher na inisyu ng Section 8 o Housing Authority, kabilang ang mga voucher para sa Veterans Affairs Supportive Housing (VASH), o tulong sa pag-upa mula sa ibang mga mapagkukunan?", "application.form.general.advocateWarning": "Nag-aaplay ka para sa isang kliyente; pakilagay ang impormasyon ng iyong kliyente, hindi ang sarili mong impormasyon.", "application.form.general.saveAndFinishLater": "I-save at tapusin pagkatapos", "application.form.general.saveAndReturn": "I-save at ibalik para repasuhin", @@ -1445,7 +1450,7 @@ "t.tryRemovingFilters": "Subukang tanggalin ang ilan sa iyong mga filter.", "t.unit": "unit", "t.unitAmenities": "Mga kagamitan sa unit", - "t.unitFeatures": "Mga tampok ng unit", + "t.unitFeatures": "Mga tampok ng unit at uri ng accessibility", "t.units": "mga unit", "t.unitType": "Uri ng unit", "t.uploadFile": "Mag-upload ng file", diff --git a/shared-helpers/src/locales/vi.json b/shared-helpers/src/locales/vi.json index 56ffa806700..c5760bc94b9 100644 --- a/shared-helpers/src/locales/vi.json +++ b/shared-helpers/src/locales/vi.json @@ -259,12 +259,17 @@ "application.financial.income.validationError.reason.low": "Thu nhập hộ gia đình của quý vị quá thấp.", "application.financial.vouchers.housingVouchers.strong": "Phiếu chọn Nhà", "application.financial.vouchers.housingVouchers.text": "như Mục 8 (Section 8)", + "application.financial.vouchers.issuedVouchers": "Phiếu hỗ trợ nhà ở theo chương trình Section 8", "application.financial.vouchers.legend": "Phiếu mua nhà, thu nhập được miễn thuế hoặc trợ cấp tiền thuê nhà", + "application.financial.vouchers.none": "Không có đáp án nào ở trên", "application.financial.vouchers.nonTaxableIncome.strong": "Thu nhập không chịu thuế", "application.financial.vouchers.nonTaxableIncome.text": "như SSI, SSDI, các khoản tiền trợ cấp nuôi con, hoặc các khoản tiền quyền lợi bồi thường cho người lao động", + "application.financial.vouchers.rentalAssistance": "Hỗ trợ tiền thuê nhà từ các nguồn khác", "application.financial.vouchers.rentalSubsidies.strong": "Các khoản trợ cấp tiền thuê nhà", "application.financial.vouchers.rentalSubsidies.text": "như VASH, HSA, HOPWA, Catholic Charities, AIDS Foundation, v.v.", + "application.financial.vouchers.subtitleAlt": "Hỗ trợ tiền thuê nhà từ các nguồn khác bao gồm hỗ trợ từ các chương trình như Catholic Charities hoặc AIDS Foundation.", "application.financial.vouchers.title": "Quý vị hoặc bất kỳ ai trong đơn ghi danh này có nhận được bất kỳ quyền lợi nào sau đây không?", + "application.financial.vouchers.titleAlt": "Bạn hoặc bất kỳ ai trong gia đình bạn có nhận được trợ cấp nhà ở theo Chương trình Section 8 hoặc trợ cấp do Cơ quan Nhà ở cấp, bao gồm cả trợ cấp Nhà ở Hỗ trợ Cựu chiến binh (VASH), hoặc trợ cấp thuê nhà từ các nguồn khác không?", "application.form.general.advocateWarning": "Bạn đang nộp đơn thay mặt cho khách hàng; vui lòng nhập thông tin của khách hàng, chứ không phải thông tin của chính bạn.", "application.form.general.saveAndFinishLater": "Lưu và hoàn thành sau", "application.form.general.saveAndReturn": "Lưu và quay lại để xem", @@ -1445,7 +1450,7 @@ "t.tryRemovingFilters": "Hãy thử gỡ bỏ bớt một số bộ lọc.", "t.unit": "căn nhà", "t.unitAmenities": "Tiện nghi đơn vị", - "t.unitFeatures": "Tính năng đơn vị", + "t.unitFeatures": "Đặc điểm căn hộ và loại hình tiếp cận", "t.units": "căn nhà", "t.unitType": "Loại đơn vị", "t.uploadFile": "Tải tệp lên", diff --git a/shared-helpers/src/locales/zh.json b/shared-helpers/src/locales/zh.json index 604d71436a8..7cc4ff963a1 100644 --- a/shared-helpers/src/locales/zh.json +++ b/shared-helpers/src/locales/zh.json @@ -259,12 +259,17 @@ "application.financial.income.validationError.reason.low": "您的家庭收入過低。", "application.financial.vouchers.housingVouchers.strong": "房屋補助券", "application.financial.vouchers.housingVouchers.text": "例如第 8 條款 (Section 8) 補助券", + "application.financial.vouchers.issuedVouchers": "第 8 节住房选择券", "application.financial.vouchers.legend": "住房補助券、免稅收入或房租補貼", + "application.financial.vouchers.none": "以上皆非", "application.financial.vouchers.nonTaxableIncome.strong": "免税收入", "application.financial.vouchers.nonTaxableIncome.text": "例如補充保障收入 (SSI)、社會保障殘疾保險 (SSDI)、子女撫養費或工傷補償福利", + "application.financial.vouchers.rentalAssistance": "来自其他来源的租金援助", "application.financial.vouchers.rentalSubsidies.strong": "租金補貼", "application.financial.vouchers.rentalSubsidies.text": "例如 VASH(退伍軍人事務支持性住房計劃)、HSA、HOPWA(愛滋病患者住房機會)、Catholic Charities(天主教慈善協會)、AIDS Foundation(愛滋病基金會)等。", + "application.financial.vouchers.subtitleAlt": "来自其他来源的租金援助包括天主教慈善机构或艾滋病基金会等项目提供的支持。", "application.financial.vouchers.title": "您或申請表所列的任何人有領取以下任何福利嗎?", + "application.financial.vouchers.titleAlt": "您或您家中的任何人是否领取第 8 条款或住房管理局发放的住房券,包括退伍军人事务部支持性住房 (VASH) 住房券,或从其他来源获得租金援助?", "application.form.general.advocateWarning": "您是代表客户申请的;请输入您客户的信息,而不是您自己的信息。", "application.form.general.saveAndFinishLater": "儲存並於稍後完成", "application.form.general.saveAndReturn": "儲存並返回檢視", @@ -1445,7 +1450,7 @@ "t.tryRemovingFilters": "尝试移除一些过滤器。", "t.unit": "單位", "t.unitAmenities": "单位设施", - "t.unitFeatures": "单位特色", + "t.unitFeatures": "单元功能和无障碍类型", "t.units": "單位", "t.unitType": "单位类型", "t.uploadFile": "上传文件", diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index eb6cd2882ca..7341f19f63c 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1712,6 +1712,25 @@ export class ApplicationsService { axios(configs, resolve, reject) }) } + /** + * Download a template CSV for bulk updating applications for a listing + */ + downloadBulkUpdateTemplate( + params: { + /** */ + listingId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applications/bulk-update/template/{listingId}" + url = url.replace("{listingId}", params["listingId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + axios(configs, resolve, reject) + }) + } /** * Submit application (used by applicants applying to a listing) */ @@ -7242,7 +7261,7 @@ export interface Application { reasonableAccommodations?: string /** */ - incomeVouchers?: boolean + incomeVouchers?: string[] /** */ income?: string @@ -8274,7 +8293,7 @@ export interface PublicAppsFiltered { reasonableAccommodations?: string /** */ - incomeVouchers?: boolean + incomeVouchers?: string[] /** */ income?: string @@ -8625,7 +8644,7 @@ export interface ApplicationCreate { reasonableAccommodations?: string /** */ - incomeVouchers?: boolean + incomeVouchers?: string[] /** */ income?: string @@ -8961,7 +8980,7 @@ export interface ApplicationUpdate { reasonableAccommodations?: string /** */ - incomeVouchers?: boolean + incomeVouchers?: string[] /** */ income?: string @@ -10756,6 +10775,7 @@ export enum FeatureFlagEnum { "enableAccessibilityFeatures" = "enableAccessibilityFeatures", "enableAdditionalResources" = "enableAdditionalResources", "enableApplicationStatus" = "enableApplicationStatus", + "enableAutoOpenDate" = "enableAutoOpenDate", "enableAutopublish" = "enableAutopublish", "enableCompanyWebsite" = "enableCompanyWebsite", "enableCustomListingNotifications" = "enableCustomListingNotifications", @@ -10785,6 +10805,7 @@ export enum FeatureFlagEnum { "enableMarketingFlyer" = "enableMarketingFlyer", "enableMarketingStatus" = "enableMarketingStatus", "enableMarketingStatusMonths" = "enableMarketingStatusMonths", + "enableMultiselectVoucherQuestion" = "enableMultiselectVoucherQuestion", "enableNeighborhoodAmenities" = "enableNeighborhoodAmenities", "enableNeighborhoodAmenitiesDropdown" = "enableNeighborhoodAmenitiesDropdown", "enableNonRegulatedListings" = "enableNonRegulatedListings", diff --git a/shared-helpers/src/utilities/blankApplication.ts b/shared-helpers/src/utilities/blankApplication.ts index 63eaf13e29d..bbc534dd384 100644 --- a/shared-helpers/src/utilities/blankApplication.ts +++ b/shared-helpers/src/utilities/blankApplication.ts @@ -99,7 +99,7 @@ export const blankApplication = { householdExpectingChanges: null, householdStudent: null, reasonableAccommodations: "", - incomeVouchers: null, + incomeVouchers: [], income: null, incomePeriod: null, householdMember: [], diff --git a/sites/partners/__tests__/components/applications/BulkUpdateDrawer.test.tsx b/sites/partners/__tests__/components/applications/BulkUpdateDrawer.test.tsx new file mode 100644 index 00000000000..6b88ce03e15 --- /dev/null +++ b/sites/partners/__tests__/components/applications/BulkUpdateDrawer.test.tsx @@ -0,0 +1,44 @@ +import React from "react" +import userEvent from "@testing-library/user-event" +import BulkUpdateDrawer from "../../../src/components/applications/BulkUpdateDrawer" +import { mockNextRouter, render, screen, within } from "../../testUtils" + +beforeAll(() => mockNextRouter()) + +describe("BulkUpdateDrawer", () => { + it("renders drawer when open", () => { + render() + expect(screen.getByRole("dialog")).toBeInTheDocument() + expect(screen.getByText("Bulk update applications")).toBeInTheDocument() + }) + + it("does not render drawer when closed", () => { + render() + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + it("calls onClose when Close button is clicked", async () => { + const onClose = jest.fn() + render() + const footer = screen.getByRole("contentinfo") + await userEvent.click(within(footer).getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it("renders all three steps", () => { + render() + expect(screen.getByText("Step 1: Download the template")).toBeInTheDocument() + expect(screen.getByText("Step 2: Make your changes")).toBeInTheDocument() + expect(screen.getByText("Step 3: Upload your file")).toBeInTheDocument() + }) + + it("renders the download template button", () => { + render() + expect(screen.getByRole("button", { name: /download template/i })).toBeInTheDocument() + }) + + it("renders the CSV dropzone", () => { + render() + expect(screen.getByText("Upload CSV")).toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdIncome.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdIncome.test.tsx index 5150a7232e5..fb45f958af8 100644 --- a/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdIncome.test.tsx +++ b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdIncome.test.tsx @@ -21,7 +21,7 @@ describe("", () => { expect(screen.getByLabelText(/per month/i)).toBeInTheDocument() expect(screen.getByLabelText(/annual income/i)).toBeInTheDocument() expect(screen.getByLabelText(/monthly income/i)).toBeInTheDocument() - expect(screen.getByLabelText(/housing voucher or subsidy/i)).toBeInTheDocument() + expect(screen.getByRole("group", { name: /housing voucher or subsidy/i })).toBeInTheDocument() }) it("should disable income value fields until income period is selected", async () => { diff --git a/sites/partners/__tests__/components/listings/ListingFormActions.test.tsx b/sites/partners/__tests__/components/listings/ListingFormActions.test.tsx index b940406542f..8589ccffb7c 100644 --- a/sites/partners/__tests__/components/listings/ListingFormActions.test.tsx +++ b/sites/partners/__tests__/components/listings/ListingFormActions.test.tsx @@ -13,11 +13,11 @@ import ListingFormActions, { } from "../../../src/components/listings/ListingFormActions" import { mockNextRouter, render } from "../../testUtils" import { - FeatureFlagEnum, UserRoleEnum, Jurisdiction, ListingsStatusEnum, User, + FeatureFlagEnum, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" afterEach(cleanup) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/PublishListingDialog.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/PublishListingDialog.test.tsx new file mode 100644 index 00000000000..3595beb73d9 --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingForm/PublishListingDialog.test.tsx @@ -0,0 +1,157 @@ +import React from "react" +import { screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import dayjs from "dayjs" +import utc from "dayjs/plugin/utc" +import { ListingsStatusEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import PublishListingDialog from "../../../../src/components/listings/PaperListingForm/dialogs/PublishListingDialog" +import { mockNextRouter, render } from "../../../testUtils" + +dayjs.extend(utc) + +describe("PublishListingDialog", () => { + beforeAll(() => { + mockNextRouter() + }) + + it("renders default publish copy when enableAutopublish is false", () => { + render( + + ) + + expect(screen.getByRole("heading", { name: "Are you sure?" })).toBeInTheDocument() + expect( + screen.getByText("Publishing will push the listing live on the public site.") + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Publish" })).toBeInTheDocument() + expect(screen.queryByText(/publish automatically/)).not.toBeInTheDocument() + }) + + it("renders default publish copy when enableAutopublish is true but no scheduled date", () => { + render( + + ) + + expect( + screen.getByText("Publishing will push the listing live on the public site.") + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Publish" })).toBeInTheDocument() + expect(screen.queryByText(/publish automatically/)).not.toBeInTheDocument() + }) + + it("renders default publish copy when enableAutopublish is true but scheduled date is in the past", () => { + jest.useFakeTimers("modern") + jest.setSystemTime(new Date("2026-06-16T00:00:01")) + + render( + + ) + + expect( + screen.getByText("Publishing will push the listing live on the public site.") + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Publish" })).toBeInTheDocument() + expect(screen.queryByText(/publish automatically/)).not.toBeInTheDocument() + jest.useRealTimers() + }) + + it("renders scheduled publish copy when enableAutopublish is true with a valid future scheduled date", () => { + jest.useFakeTimers("modern") + jest.setSystemTime(new Date("2026-06-14T23:30:00")) + + render( + + ) + + expect(screen.getByRole("heading", { name: "Are you sure?" })).toBeInTheDocument() + expect( + screen.getByText( + "This listing will publish automatically on 06/15/2026 between 12:00 AM and 2:00 AM." + ) + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Publish" })).not.toBeInTheDocument() + jest.useRealTimers() + }) + + it("submits with scheduled status when confirming with a future scheduled date", async () => { + const setOpen = jest.fn() + const submitFormWithStatus = jest.fn() + + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Submit" })) + + expect(setOpen).toHaveBeenCalledWith(false) + expect(submitFormWithStatus).toHaveBeenCalledWith("redirect", ListingsStatusEnum.scheduled) + }) + + it("submits with active status when confirming without a scheduled date", async () => { + const setOpen = jest.fn() + const submitFormWithStatus = jest.fn() + + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Publish" })) + + expect(setOpen).toHaveBeenCalledWith(false) + expect(submitFormWithStatus).toHaveBeenCalledWith("redirect", ListingsStatusEnum.active) + }) + + it("closes without submitting when cancel is clicked", async () => { + const setOpen = jest.fn() + const submitFormWithStatus = jest.fn() + + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Cancel" })) + + expect(setOpen).toHaveBeenCalledWith(false) + expect(submitFormWithStatus).not.toHaveBeenCalled() + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/SaveScheduledListingDialog.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/SaveScheduledListingDialog.test.tsx new file mode 100644 index 00000000000..be8885788c9 --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingForm/SaveScheduledListingDialog.test.tsx @@ -0,0 +1,70 @@ +import React from "react" +import { screen } from "@testing-library/react" +import SaveScheduledListingDialog from "../../../../src/components/listings/PaperListingForm/dialogs/SaveScheduledListingDialog" +import { mockNextRouter, render } from "../../../testUtils" + +describe("SaveScheduledListingDialog", () => { + beforeAll(() => { + mockNextRouter() + }) + + beforeEach(() => { + jest.useFakeTimers("modern") + jest.setSystemTime(new Date("2026-06-15T12:00:00.000Z")) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it("renders no-date copy when currentScheduledPublishAt is null", () => { + render( + + ) + + expect( + screen.getByText( + "This listing no longer has a scheduled publish date entered. As it is already approved, saving will publish it immediately. To delay publication, set a publish date before saving, or Unapprove the listing." + ) + ).toBeInTheDocument() + }) + + it("renders past-date copy when currentScheduledPublishAt is in the past", () => { + render( + + ) + + expect( + screen.getByText( + "The scheduled publish date entered on this listing is in the past. As it is already approved, saving will publish it immediately. To delay publication, update the scheduled publish date before saving, or Unapprove the listing." + ) + ).toBeInTheDocument() + }) + + it("renders scheduled copy with formatted date when currentScheduledPublishAt is in the future", () => { + render( + + ) + + expect( + screen.getByText( + "This listing is approved and scheduled to publish on 06/20/2026 between 12:00 AM and 2:00 AM. Saving will not affect its scheduled publication on this date." + ) + ).toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationDates.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationDates.test.tsx index daa6672922a..72f2a65b145 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationDates.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationDates.test.tsx @@ -202,6 +202,7 @@ describe("ApplicationDates", () => { { { + beforeEach(() => { + jest.useFakeTimers("modern") + jest.setSystemTime(new Date("2026-06-15T12:00:00.000Z")) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it("returns false for null", () => { + expect(getValidFutureScheduledDate(null)).toBe(false) + }) + + it("returns false for undefined", () => { + expect(getValidFutureScheduledDate(undefined)).toBe(false) + }) + + it("returns false for an invalid date string", () => { + expect(getValidFutureScheduledDate("not-a-date")).toBe(false) + }) + + it("returns false for a date in the past", () => { + expect(getValidFutureScheduledDate("2026-06-14T00:00:00.000Z")).toBe(false) + }) + + it("returns false for today's UTC date (same day, not strictly in the future)", () => { + expect(getValidFutureScheduledDate("2026-06-15T00:00:00.000Z")).toBe(false) + }) + + it("returns the formatted date string for a date in the future", () => { + expect(getValidFutureScheduledDate("2026-06-16T00:00:00.000Z")).toBe("06/16/2026") + }) + + it("returns the formatted date string when passed a Date object", () => { + expect(getValidFutureScheduledDate(new Date("2026-06-20T00:00:00.000Z"))).toBe("06/20/2026") + }) +}) diff --git a/sites/partners/__tests__/lib/listings/DatesFormatter.test.ts b/sites/partners/__tests__/lib/listings/DatesFormatter.test.ts index 0714efb1686..daa5948ab43 100644 --- a/sites/partners/__tests__/lib/listings/DatesFormatter.test.ts +++ b/sites/partners/__tests__/lib/listings/DatesFormatter.test.ts @@ -5,8 +5,7 @@ import { FormMetadata } from "../../../src/lib/listings/formTypes" import { YesNoEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" // test helpers -const metadata = {} as FormMetadata -const formatData = (data) => { +const formatData = (data, metadata = {} as FormMetadata) => { return new DatesFormatter({ ...data }, metadata).format().data } const dueDate = { @@ -51,29 +50,53 @@ describe("DatesFormatter", () => { const data = { scheduledListingPublishDateField: { year: "2030", month: "06", day: "15" }, } - expect(formatData(data).scheduledPublishAt?.toISOString()).toEqual("2030-06-15T00:00:00.000Z") + expect( + formatData(data, { + enableAutopublish: true, + } as FormMetadata).scheduledPublishAt?.toISOString() + ).toEqual("2030-06-15T00:00:00.000Z") }) it("should set scheduledPublishAt to null when scheduled date field is incomplete", () => { const data = { scheduledListingPublishDateField: { year: "2030", month: "", day: "" }, } + expect( + formatData(data, { enableAutopublish: true } as FormMetadata).scheduledPublishAt + ).toBeNull() + }) + + it("should set scheduledPublishAt to null when enableAutopublish is disabled", () => { + const data = { + scheduledListingPublishDateField: { year: "2030", month: "06", day: "15" }, + } expect(formatData(data).scheduledPublishAt).toBeNull() }) - it("should set scheduledApplicationOpenAt to UTC midnight when open date field is complete", () => { + it("should set scheduledApplicationOpenAt to UTC midnight when open date field is complete and flag is enabled", () => { const data = { scheduledApplicationOpenDateField: { year: "2030", month: "06", day: "15" }, } - expect(formatData(data).scheduledApplicationOpenAt?.toISOString()).toEqual( - "2030-06-15T00:00:00.000Z" - ) + expect( + formatData(data, { + enableAutoOpenDate: true, + } as FormMetadata).scheduledApplicationOpenAt?.toISOString() + ).toEqual("2030-06-15T00:00:00.000Z") }) it("should set scheduledApplicationOpenAt to null when open date field is incomplete", () => { const data = { scheduledApplicationOpenDateField: { year: "2030", month: "", day: "" }, } + expect( + formatData(data, { enableAutoOpenDate: true } as FormMetadata).scheduledApplicationOpenAt + ).toBeNull() + }) + + it("should set scheduledApplicationOpenAt to null when enableAutoOpenDate is disabled", () => { + const data = { + scheduledApplicationOpenDateField: { year: "2030", month: "06", day: "15" }, + } expect(formatData(data).scheduledApplicationOpenAt).toBeNull() }) }) diff --git a/sites/partners/__tests__/pages/listings/[id]/index.test.tsx b/sites/partners/__tests__/pages/listings/[id]/index.test.tsx index d497779a715..8270adaad06 100644 --- a/sites/partners/__tests__/pages/listings/[id]/index.test.tsx +++ b/sites/partners/__tests__/pages/listings/[id]/index.test.tsx @@ -1678,6 +1678,34 @@ describe("listing data", () => { expect(screen.getByText("Accessible marketing flyer")).toBeInTheDocument() expect(screen.getByText("file_id_2.pdf")).toBeInTheDocument() }) + + it("should display scheduled application open date only when enableAutoOpenDate is enabled", () => { + render( + + featureFlag === FeatureFlagEnum.enableAutoOpenDate, + getJurisdiction: () => jurisdiction, + }} + > + + + + + ) + + expect(screen.queryByText("Scheduled listing publish date")).not.toBeInTheDocument() + expect(screen.getByText("Scheduled application open date")).toBeInTheDocument() + expect(screen.getByText("06/16/2030")).toBeInTheDocument() + }) }) describe("should display Verification section", () => { diff --git a/sites/partners/__tests__/pages/listings/applications/index.test.tsx b/sites/partners/__tests__/pages/listings/applications/index.test.tsx index 49dea52698f..c22a609ba61 100644 --- a/sites/partners/__tests__/pages/listings/applications/index.test.tsx +++ b/sites/partners/__tests__/pages/listings/applications/index.test.tsx @@ -184,7 +184,8 @@ describe("applications", () => { expect(getByText("Friend")).toBeInTheDocument() expect(getByText("Same address as primary HH:1")).toBeInTheDocument() expect(getByText("Work in region HH:1")).toBeInTheDocument() - expect(getAllByText("Yes")).toHaveLength(4) + expect(getAllByText("Yes")).toHaveLength(3) + expect(getByText("incomeVoucher")).toBeInTheDocument() expect(getByText("Flagged as duplicate")).toBeInTheDocument() expect(getByText("Marked as duplicate")).toBeInTheDocument() expect(getByText("No")).toBeInTheDocument() diff --git a/sites/partners/cypress/e2e/default/05-paperApplication.spec.ts b/sites/partners/cypress/e2e/default/05-paperApplication.spec.ts index 24c67f2e01a..8cb705d56e2 100644 --- a/sites/partners/cypress/e2e/default/05-paperApplication.spec.ts +++ b/sites/partners/cypress/e2e/default/05-paperApplication.spec.ts @@ -29,7 +29,6 @@ describe("Paper Application Tests", () => { }) }) - // TODO: unskip when application flow is connected it("submit with no data", () => { cy.fixture("emptyApplication").then((application) => { cy.fillTerms(application, true) diff --git a/sites/partners/cypress/e2e/listings-approval/listings-autopublish.spec.ts b/sites/partners/cypress/e2e/listings-approval/listings-autopublish.spec.ts new file mode 100644 index 00000000000..69f6fafd7c4 --- /dev/null +++ b/sites/partners/cypress/e2e/listings-approval/listings-autopublish.spec.ts @@ -0,0 +1,86 @@ +describe("Listings autopublish feature", () => { + const futurePublishDate = new Date() + futurePublishDate.setFullYear(futurePublishDate.getFullYear() + 1) + + const uniqueListingName = `autopublish-${Date.now()}` + const adminDraftListingName = uniqueListingName + + it("should approve to scheduled and publish from scheduled with autopublish enabled", () => { + cy.intercept("POST", "https://api.cloudinary.com/v1_1/exygy/upload", { + fixture: "cypressUpload", + }) + cy.intercept( + "GET", + "https://res.cloudinary.com/exygy/image/upload/w_400,c_limit,q_65/dev/cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpg", + { + fixture: "cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpg", + } + ) + + cy.loginApi("user") + cy.visit("/") + + // Create a name-only draft listing in Angelopolis as admin + cy.visit("/") + cy.getByID("addListingButton").contains("Add listing").click() + cy.getByID("jurisdiction").select("Angelopolis") + cy.get("button").contains("Get started").click() + cy.contains("New listing") + cy.getByID("name").type(adminDraftListingName) + cy.getByID("saveDraftButton").contains("Save as draft").click() + cy.getByTestId("page-header").should("have.text", adminDraftListingName) + + // Give partner user listing access in Angelopolis + cy.visit("/") + cy.getByTestId("Users-1").click() + cy.contains("Users") + cy.getByTestId("ag-search-input") + .should("be.visible") + .type("partner@example.com", { force: true }) + cy.getByID("user-link-partner@example.com").first().click() + + // Ensure Angelopolis is selected as a partner jurisdiction so listing checkboxes render + cy.get("body").then(($body) => { + if ($body.find("[data-testid='listings-all-Angelopolis']").length === 0) { + cy.contains("label", "Angelopolis").click({ force: true }) + } + }) + cy.getByTestId("listings-all-Angelopolis").should("exist") + cy.getByTestId("listings-all-Angelopolis").check({ force: true }) + cy.getByID("save-user").click() + cy.signOutApi("user") + + cy.loginApi("partnerUser") + cy.visit("/") + + // Submit scheduled listing for approval + cy.addMinimalListing( + uniqueListingName, + false, + true, + false, + futurePublishDate, + adminDraftListingName + ) + cy.getByID("listing-status-pending-review").should("be.visible") + cy.signOutApi("partnerUser") + + // Approve listing to move to scheduled + cy.loginApi("user") + cy.visit("/") + cy.findAndOpenListing(uniqueListingName) + cy.getByID("approveAndPublishButton").click() + cy.getByID("adminListingApprovalButtonConfirm").click() + + cy.findAndOpenListing(uniqueListingName) + cy.getByID("listing-status-scheduled").should("be.visible") + + // Override scheduling and publish + cy.getByID("publishScheduledButton").click() + cy.getByID("publishScheduledListingButtonConfirm").click() + + cy.findAndOpenListing(uniqueListingName) + cy.getByID("listing-status-active").should("be.visible") + cy.signOutApi() + }) +}) diff --git a/sites/partners/cypress/fixtures/alternateContactOnlyData.json b/sites/partners/cypress/fixtures/alternateContactOnlyData.json index 0e61eb93c7a..48503e7d029 100644 --- a/sites/partners/cypress/fixtures/alternateContactOnlyData.json +++ b/sites/partners/cypress/fixtures/alternateContactOnlyData.json @@ -35,6 +35,7 @@ "incomePeriod": "n/a", "incomeMonth": "n/a", "incomeVouchers": "No", + "vouchers": "n/a", "demographics.ethnicity": "n/a", "acceptedTerms": "Yes", "firstName": "n/a", diff --git a/sites/partners/cypress/fixtures/applicantOnlyData.json b/sites/partners/cypress/fixtures/applicantOnlyData.json index 9ed74656b70..e9b854565c9 100644 --- a/sites/partners/cypress/fixtures/applicantOnlyData.json +++ b/sites/partners/cypress/fixtures/applicantOnlyData.json @@ -35,6 +35,7 @@ "incomePeriod": "n/a", "incomeMonth": "n/a", "incomeVouchers": "No", + "vouchers": "n/a", "demographics.ethnicity": "n/a", "acceptedTerms": "Yes", "firstName": "n/a", diff --git a/sites/partners/cypress/fixtures/application.json b/sites/partners/cypress/fixtures/application.json index 979cc45393d..8a48e24a03f 100644 --- a/sites/partners/cypress/fixtures/application.json +++ b/sites/partners/cypress/fixtures/application.json @@ -35,6 +35,7 @@ "incomePeriod": "Month", "incomeMonth": "5000", "incomeVouchers": "No", + "vouchers": "No", "demographics.ethnicity": "Not Hispanic / Latino", "acceptedTerms": "Yes", "firstName": "HouseHold First Name", diff --git a/sites/partners/cypress/fixtures/demographicsOnlyData.json b/sites/partners/cypress/fixtures/demographicsOnlyData.json index 5fdee713742..9b56bb8ac6b 100644 --- a/sites/partners/cypress/fixtures/demographicsOnlyData.json +++ b/sites/partners/cypress/fixtures/demographicsOnlyData.json @@ -35,6 +35,7 @@ "incomePeriod": "n/a", "incomeMonth": "n/a", "incomeVouchers": "No", + "vouchers": "n/a", "demographics.ethnicity": "Not Hispanic / Latino", "acceptedTerms": "Yes", "firstName": "n/a", diff --git a/sites/partners/cypress/fixtures/emptyApplication.json b/sites/partners/cypress/fixtures/emptyApplication.json index 1d1ae5e586e..b9d8de5d045 100644 --- a/sites/partners/cypress/fixtures/emptyApplication.json +++ b/sites/partners/cypress/fixtures/emptyApplication.json @@ -36,6 +36,7 @@ "incomePeriod": "n/a", "incomeMonth": "n/a", "incomeVouchers": "No", + "vouchers": "n/a", "demographics.ethnicity": "n/a", "acceptedTerms": "Yes", "firstName": "n/a", diff --git a/sites/partners/cypress/fixtures/householdDetailsOnlyData.json b/sites/partners/cypress/fixtures/householdDetailsOnlyData.json index 93d8bff77b8..ff9fb51bc3c 100644 --- a/sites/partners/cypress/fixtures/householdDetailsOnlyData.json +++ b/sites/partners/cypress/fixtures/householdDetailsOnlyData.json @@ -35,6 +35,7 @@ "incomePeriod": "n/a", "incomeMonth": "n/a", "incomeVouchers": "No", + "vouchers": "n/a", "demographics.ethnicity": "n/a", "acceptedTerms": "Yes", "firstName": "n/a", diff --git a/sites/partners/cypress/fixtures/householdIncomeOnlyData.json b/sites/partners/cypress/fixtures/householdIncomeOnlyData.json index 6068fcee776..c51bf6c0091 100644 --- a/sites/partners/cypress/fixtures/householdIncomeOnlyData.json +++ b/sites/partners/cypress/fixtures/householdIncomeOnlyData.json @@ -35,6 +35,7 @@ "incomePeriod": "Month", "incomeMonth": "6000", "incomeVouchers": "Yes", + "vouchers": "Yes", "demographics.ethnicity": "n/a", "acceptedTerms": "Yes", "firstName": "n/a", diff --git a/sites/partners/cypress/fixtures/householdMemberOnlyData.json b/sites/partners/cypress/fixtures/householdMemberOnlyData.json index 818e388730f..51e5d00397d 100644 --- a/sites/partners/cypress/fixtures/householdMemberOnlyData.json +++ b/sites/partners/cypress/fixtures/householdMemberOnlyData.json @@ -35,6 +35,7 @@ "incomePeriod": "n/a", "incomeMonth": "n/a", "incomeVouchers": "No", + "vouchers": "n/a", "demographics.ethnicity": "n/a", "acceptedTerms": "Yes", "firstName": "household", diff --git a/sites/partners/cypress/fixtures/partialApplicationA.json b/sites/partners/cypress/fixtures/partialApplicationA.json index 2570aa22039..7d715850284 100644 --- a/sites/partners/cypress/fixtures/partialApplicationA.json +++ b/sites/partners/cypress/fixtures/partialApplicationA.json @@ -35,6 +35,7 @@ "incomePeriod": "Year", "incomeMonth": "n/a", "incomeVouchers": "No", + "vouchers": "No", "demographics.ethnicity": "n/a", "acceptedTerms": "Yes", "firstName": "n/a", diff --git a/sites/partners/cypress/support/commands.js b/sites/partners/cypress/support/commands.js index 1bf6dadfd24..459f1dcfad5 100644 --- a/sites/partners/cypress/support/commands.js +++ b/sites/partners/cypress/support/commands.js @@ -258,7 +258,13 @@ Cypress.Commands.add("fillHouseholdIncome", (application, fieldsToSkip = []) => cy.getByID("incomeYear").type(application["incomeYear"]) } if (!fieldsToSkip.includes("application.incomeVouchers")) { - cy.getByID("application.incomeVouchers").select(application["incomeVouchers"]) + if (application["incomeVouchers"] === "No") { + cy.getByID(`incomeVoucherNo`).click() + } else if (application["incomeVouchers"] === "Yes") { + cy.getByID(`incomeVoucherYes`).click() + } else { + cy.getByID(`application.incomeVouchers.${application["incomeVouchers"]}`).click() + } } }) @@ -395,7 +401,7 @@ Cypress.Commands.add("verifyHouseholdIncome", (application, fieldsToSkip = []) = const fields = [ { id: "annualIncome", fieldKey: "annualIncome" }, { id: "monthlyIncome", fieldKey: "formattedMonthlyIncome" }, - { id: "vouchers", fieldKey: "incomeVouchers" }, + { id: "vouchers", fieldKey: "vouchers" }, ] verifyHelper(application, fields, fieldsToSkip) }) @@ -404,82 +410,191 @@ Cypress.Commands.add("verifyTerms", (application) => { cy.getByTestId("signatureOnTerms").contains(application["acceptedTerms"]) }) -Cypress.Commands.add("addMinimalListing", (listingName, isLottery, isApproval, jurisdiction) => { - // Create and publish minimal FCFS or Lottery listing - // TODO: test Open Waitlist, though maybe with integration test instead - cy.getByID("addListingButton").contains("Add listing").click() - if (jurisdiction) { - cy.getByID("jurisdiction").select("Bloomington") - cy.get("button").contains("Get started").click() - } - cy.contains("New listing") - cy.fixture("minimalListing").then((listing) => { - cy.getByID("name").type(listingName) - cy.getByID("developer").type(listing["developer"]) - cy.getByID("add-photos-button").contains("Add photo").click() - cy.getByTestId("dropzone-input").attachFile( - "cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpg", - { - subjectType: "drag-n-drop", +Cypress.Commands.add( + "addMinimalListing", + (listingName, isLottery, isApproval, jurisdiction, scheduledPublishDate, existingListingName) => { + // Create and publish minimal FCFS or Lottery listing + // TODO: test Open Waitlist, though maybe with integration test instead + if (existingListingName) { + cy.findAndOpenListing(existingListingName) + cy.getByID("listingEditButton").contains("Edit").click() + } else { + cy.getByID("addListingButton").contains("Add listing").click() + if (jurisdiction) { + // jurisdiction can be a boolean (defaults to "Bloomington") or a jurisdiction name string + const jurisdictionName = typeof jurisdiction === "string" ? jurisdiction : "Bloomington" + cy.getByID("jurisdiction").select(jurisdictionName) + cy.get("button").contains("Get started").click() } - ) - cy.getByTestId("drawer-photos-table") - .find("img") - .should("have.attr", "src") - .should("include", "cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96") - cy.getByID("listing-photo-uploaded").contains("Save").click() - cy.getByID("listingsBuildingAddress.street").type(listing["buildingAddress.street"]) - cy.getByID("neighborhood").type(listing["neighborhood"]) - cy.getByID("listingsBuildingAddress.city").type(listing["buildingAddress.city"]) - cy.getByID("listingsBuildingAddress.state").select(listing["buildingAddress.state"]) - cy.getByID("listingsBuildingAddress.zipCode").type(listing["buildingAddress.zipCode"]) - cy.getByID("addUnitsButton").contains("Add unit").click() - cy.getByID("number").type(listing["number"]) - cy.getByID("unitTypes.id").select(listing["unitType.id"]) - cy.getByID("unitFormSaveAndExitButton").contains("Save & exit").should("be.visible").click() - cy.getByID("amiChart.id").select(1).trigger("change") - cy.getByID("amiPercentage").select(1) - cy.getByID("unitFormSaveAndExitButton").contains("Save & exit").click() - cy.get("button").contains("Application process").click() - if (isLottery) { - cy.getByID("reviewOrderLottery").check() - cy.getByTestId("lottery-start-date-month").type("1") - cy.getByTestId("lottery-start-date-day").type("17") - cy.getByTestId("lottery-start-date-year").type("2026") - cy.getByTestId("lottery-start-time-hours").type("9") - cy.getByTestId("lottery-start-time-minutes").type("00") - cy.getByTestId("lottery-start-time-period").select("AM") - cy.getByTestId("lottery-end-time-hours").type("10") - cy.getByTestId("lottery-end-time-minutes").type("00") - cy.getByTestId("lottery-end-time-period").select("AM") + cy.contains("New listing") } - cy.getByID("leasingAgentName").type(listing["leasingAgentName"]) - cy.getByID("leasingAgentEmail").type(listing["leasingAgentEmail"]) - cy.getByID("leasingAgentPhone").type(listing["leasingAgentPhone"]) - cy.getByID("digitalApplicationChoiceYes").check() - cy.getByID("commonDigitalApplicationChoiceYes").check() - cy.getByID("paperApplicationNo").check() - cy.getByID("referralOpportunityNo").check() - cy.getByID("applicationDueDateField.month").type(listing["date.month"]) - cy.getByID("applicationDueDateField.day").type(listing["date.day"]) - cy.getByID("applicationDueDateField.year").type((new Date().getFullYear() + 1).toString()) - cy.getByID("applicationDueTimeField.hours").type(listing["date.hours"]) - cy.getByID("applicationDueTimeField.minutes").type(listing["date.minutes"]) - cy.getByID("applicationDueTimeField.period").select("PM") - }) - if (isApproval) { - cy.getByID("submitButton").contains("Submit").click() - cy.getByID("submitListingForApprovalButtonConfirm").contains("Submit").click() - cy.getByTestId("page-header").should("be.visible") - cy.getByTestId("page-header").should("have.text", listingName) - } else { - cy.getByID("publishButton").contains("Publish").click() - cy.getByID("publishButtonConfirm").contains("Publish").click() - cy.get("[data-testid=page-header]").should("be.visible") - cy.getByTestId("page-header").should("have.text", listingName) + cy.fixture("minimalListing").then((listing) => { + const uploadListingPhoto = ( + fixtureName, + description, + isFirstUpload = false, + requiresAltText = false, + imageIndex = 0 + ) => { + const publicId = `dev/${fixtureName.replace(".jpg", "")}` + + // Use per-image intercepts so each upload resolves to its own Cloudinary URL. + cy.intercept("POST", "https://api.cloudinary.com/v1_1/exygy/upload", { + public_id: publicId, + }) + cy.intercept( + "GET", + `https://res.cloudinary.com/exygy/image/upload/w_400,c_limit,q_65/${publicId}.jpg`, + { + fixture: fixtureName, + } + ) + + cy.getByID("add-photos-button") + .contains(isFirstUpload ? "Add photo" : "Edit photos") + .click() + cy.getByTestId("dropzone-input").attachFile(fixtureName, { + subjectType: "drag-n-drop", + }) + // Angelopolis-style listings require alt text before a photo is added to the drawer table. + if (requiresAltText) { + cy.get("#image-description").should("be.visible") + cy.getByID("image-description").clear().type(description) + cy.getByID("save-alt-text-button").click() + } + cy.getByTestId("drawer-photos-table").should("be.visible") + cy.getByID(`listing-drawer-image-${imageIndex}`) + .should("have.attr", "src") + .should("include", fixtureName.replace(".jpg", "")) + cy.getByID("listing-photo-uploaded").contains("Save").click() + } + + cy.getByID("name").clear().type(listingName) + cy.getByID("developer").type(listing["developer"]) + + cy.get("body").then(($body) => { + const hasListingFileNumber = $body.find("#listingFileNumber").length > 0 + + if (hasListingFileNumber) { + cy.getByID("listingFileNumber").type(`AUTO-${Date.now()}`) + } + + if ($body.find("[id='property.id']").length > 0) { + cy.getByID("property.id") + .find("option") + .eq(1) + .invoke("val") + .then((val) => { + cy.getByID("property.id").select(val) + }) + } + + // Angelopolis requires three images and image descriptions. + if (hasListingFileNumber) { + uploadListingPhoto( + "cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpg", + "Building exterior photo", + true, + true, + 0 + ) + uploadListingPhoto( + "cypress-automated-image-upload-46806882-b98d-49d7-ac83-8016ab4b2f08.jpg", + "Building lobby photo", + false, + true, + 1 + ) + uploadListingPhoto( + "cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpg", + "Building entrance photo", + false, + true, + 2 + ) + } else { + uploadListingPhoto( + "cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpg", + "Building exterior photo", + true + ) + } + }) + + cy.getByID("listingsBuildingAddress.street").type(listing["buildingAddress.street"]) + cy.getByID("neighborhood").type(listing["neighborhood"]) + cy.getByID("listingsBuildingAddress.city").type(listing["buildingAddress.city"]) + cy.getByID("listingsBuildingAddress.state").select(listing["buildingAddress.state"]) + cy.getByID("listingsBuildingAddress.zipCode").type(listing["buildingAddress.zipCode"]) + cy.getByID("addUnitsButton").contains("Add unit").click() + cy.getByID("number").type(listing["number"]) + cy.getByID("unitTypes.id").select(listing["unitType.id"]) + cy.getByID("unitFormSaveAndExitButton").contains("Save & exit").should("be.visible").click() + cy.getByID("amiChart.id").select(1).trigger("change") + cy.getByID("amiPercentage").select(1) + cy.getByID("unitFormSaveAndExitButton").contains("Save & exit").click() + + // Some jurisdictions require at least one accessibility feature selection. + cy.get("body").then(($body) => { + if ($body.find("#addFeaturesButton").length > 0) { + cy.getByID("addFeaturesButton").contains("Add features").click() + cy.get("body").then(($featureBody) => { + if ($featureBody.find("#carpetInUnit").length > 0) { + cy.getByID("carpetInUnit").check({ force: true }) + } + }) + cy.getByID("saveFeaturesButton").contains("Save").click() + } + }) + + cy.get("button").contains("Application process").click() + if (scheduledPublishDate) { + const d = new Date(scheduledPublishDate) + cy.getByID("scheduledListingPublishDateField.month").type(`${d.getMonth() + 1}`) + cy.getByID("scheduledListingPublishDateField.day").type(`${d.getDate()}`) + cy.getByID("scheduledListingPublishDateField.year").type(`${d.getFullYear()}`) + } + if (isLottery) { + cy.getByID("reviewOrderLottery").check() + cy.getByTestId("lottery-start-date-month").type("1") + cy.getByTestId("lottery-start-date-day").type("17") + cy.getByTestId("lottery-start-date-year").type("2026") + cy.getByTestId("lottery-start-time-hours").type("9") + cy.getByTestId("lottery-start-time-minutes").type("00") + cy.getByTestId("lottery-start-time-period").select("AM") + cy.getByTestId("lottery-end-time-hours").type("10") + cy.getByTestId("lottery-end-time-minutes").type("00") + cy.getByTestId("lottery-end-time-period").select("AM") + } + cy.getByID("leasingAgentName").type(listing["leasingAgentName"]) + cy.getByID("leasingAgentEmail").type(listing["leasingAgentEmail"]) + cy.getByID("leasingAgentPhone").type(listing["leasingAgentPhone"]) + cy.getByID("digitalApplicationChoiceYes").check() + cy.getByID("commonDigitalApplicationChoiceYes").check() + cy.getByID("paperApplicationNo").check() + cy.getByID("referralOpportunityNo").check() + cy.getByID("applicationDueDateField.month").type(listing["date.month"]) + cy.getByID("applicationDueDateField.day").type(listing["date.day"]) + cy.getByID("applicationDueDateField.year").type((new Date().getFullYear() + 1).toString()) + cy.getByID("applicationDueTimeField.hours").type(listing["date.hours"]) + cy.getByID("applicationDueTimeField.minutes").type(listing["date.minutes"]) + cy.getByID("applicationDueTimeField.period").select("PM") + }) + + if (isApproval) { + cy.getByID("submitButton").contains("Submit").click() + cy.getByID("submitListingForApprovalButtonConfirm").contains("Submit").click() + cy.getByTestId("page-header").should("be.visible") + cy.getByTestId("page-header").should("contain.text", listingName) + } else { + cy.getByID("publishButton").contains("Publish").click() + cy.getByID("publishButtonConfirm").contains("Publish").click() + cy.get("[data-testid=page-header]").should("be.visible") + cy.getByTestId("page-header").should("contain.text", listingName) + } } -}) +) Cypress.Commands.add("addMinimalApplication", (listingName) => { cy.visit("/") diff --git a/sites/partners/cypress/support/index.d.ts b/sites/partners/cypress/support/index.d.ts index 815a93432d7..2ab5572db1f 100644 --- a/sites/partners/cypress/support/index.d.ts +++ b/sites/partners/cypress/support/index.d.ts @@ -34,7 +34,7 @@ declare namespace Cypress { verifyTerms(value: Record, fieldsToSkip?: string[]): Chainable fillMailingAddress(value: Record, fieldsToSkip?: string[]): Chainable signOut(): Chainable - signOutApi(): Chainable + signOutApi(fixture?: string): Chainable fillFields( obj: Record, fieldsToType: fieldObj[], @@ -46,7 +46,9 @@ declare namespace Cypress { listingName: string, isLottery: boolean, isApproval: boolean, - jurisdiction: boolean + jurisdiction: boolean | string, + scheduledPublishDate?: Date, + existingListingName?: string ): Chainable addMinimalApplication(listingName: string): Chainable findAndOpenListing(listingName: string): Chainable diff --git a/sites/partners/page_content/locales/general.json b/sites/partners/page_content/locales/general.json index 2d97b161828..4c38e41781a 100644 --- a/sites/partners/page_content/locales/general.json +++ b/sites/partners/page_content/locales/general.json @@ -113,6 +113,17 @@ "applications.addConfirmModalContent": "This listing has closed. Are you sure you want to add a paper application?", "applications.addConfirmModalHeader": "Confirmation needed", "applications.allApplications": "All applications", + "applications.bulkUpdate": "Bulk update", + "applications.bulkUpdateDownloadTemplate": "Download template", + "applications.bulkUpdateModalSubtitle": "Update multiple applications at once using a CSV file", + "applications.bulkUpdateModalTitle": "Bulk update applications", + "applications.bulkUpdateStep1Body": "Download a pre-filled file containing all applications for this listing. The file includes read-only context columns to help you identify records, and editable columns for status, decline reason, decline reason additional details, waitlist positions, and lottery position.", + "applications.bulkUpdateStep1Title": "Step 1: Download the template", + "applications.bulkUpdateStep2Body": "Edit only the editable columns. Do not add, remove, or reorder columns, and do not modify application IDs or the context columns — any mismatches will cause the upload to fail and no records will be updated.", + "applications.bulkUpdateStep2Title": "Step 2: Make your changes", + "applications.bulkUpdateStep3Body": "Your file will be validated before any changes are saved. If errors are found, the upload will fail immediately with details about which rows need to be corrected. Once validated, changes are processed in the background — you'll receive an email when the job completes or if it encounters an error. It could take up to an hour depending on how many rows are being edited. You are safe to leave this page once it begins and can check back here for progress.", + "applications.bulkUpdateStep3DropzoneLabel": "Upload CSV", + "applications.bulkUpdateStep3Title": "Step 3: Upload your file", "applications.combination": "Combination", "applications.duplicate": "Duplicate", "applications.duplicates.duplicateApplications": "Duplicate applications", @@ -261,6 +272,7 @@ "listings.applicationType.referral": "Referral", "listings.applicationType.referralUnit": "Referral only units", "listings.approval.adminApproveDialogTitle": "Approve listing", + "listings.approval.adminPublishWithScheduledDate": "This listing will publish automatically on %{date} between 12:00 AM and 2:00 AM.", "listings.approval.adminApproveNoScheduledDate": "This listing has no scheduled publish date entered. Approving it will publish it immediately. To delay publication, set a publish date before approving.", "listings.approval.adminApproveScheduledFuture": "This listing is scheduled to publish automatically on %{date} between 12:00 AM and 2:00 AM. Approving now will not publish it immediately.", "listings.approval.adminApproveScheduledPast": "The scheduled publish date entered on the listing has passed. Approving this listing will publish it immediately. To delay publication, update the scheduled publish date before approving.", @@ -277,6 +289,7 @@ "listings.approval.reopen": "Reopen", "listings.approval.saveScheduledNoDate": "This listing no longer has a scheduled publish date entered. As it is already approved, saving will publish it immediately. To delay publication, set a publish date before saving, or Unapprove the listing.", "listings.approval.saveScheduledWithDate": "This listing is approved and scheduled to publish on %{date} between 12:00 AM and 2:00 AM. Saving will not affect its scheduled publication on this date.", + "listings.approval.saveScheduledWithPastDate": "The scheduled publish date entered on this listing is in the past. As it is already approved, saving will publish it immediately. To delay publication, update the scheduled publish date before saving, or Unapprove the listing.", "listings.approval.unapprove": "Unapprove", "listings.approval.unapproveDialogContent": "This listing will be returned to under review status and will no longer be scheduled to automatically publish. It will need to be re-approved before it can be published.", "listings.approval.unapproveDialogTitle": "Unapprove listing", diff --git a/sites/partners/src/components/applications/ApplicationsColDefs.tsx b/sites/partners/src/components/applications/ApplicationsColDefs.tsx index 7870c1310b0..5ab12c6b572 100644 --- a/sites/partners/src/components/applications/ApplicationsColDefs.tsx +++ b/sites/partners/src/components/applications/ApplicationsColDefs.tsx @@ -213,9 +213,9 @@ export function getColDefs( width: 100, minWidth: 50, valueFormatter: (data) => { - if (!data.value) return "" + if (!data.value || data.value.length === 0) return t("t.no") - return data.value ? t("t.yes") : t("t.no") + return data.value.join(", ") }, comparator: compareStrings, }, diff --git a/sites/partners/src/components/applications/BulkUpdateDrawer.tsx b/sites/partners/src/components/applications/BulkUpdateDrawer.tsx new file mode 100644 index 00000000000..3d9fc5bca5e --- /dev/null +++ b/sites/partners/src/components/applications/BulkUpdateDrawer.tsx @@ -0,0 +1,68 @@ +import React from "react" +import { Button, Card, Drawer, Heading } from "@bloom-housing/ui-seeds" +import { Dropzone, t } from "@bloom-housing/ui-components" + +interface BulkUpdateDrawerProps { + isOpen: boolean + onClose: () => void +} + +const BulkUpdateDrawer = ({ isOpen, onClose }: BulkUpdateDrawerProps) => { + const csvUploader = (file: File) => { + console.log(file) + } + + const downloadTemplate = () => { + console.log("download template") + } + + return ( + + + {t("applications.bulkUpdateModalTitle")} + + + + + + {t("applications.bulkUpdateModalSubtitle")} + + + + {t("applications.bulkUpdateStep1Title")} + +

{t("applications.bulkUpdateStep1Body")}

+ +
+ + + {t("applications.bulkUpdateStep2Title")} + +

{t("applications.bulkUpdateStep2Body")}

+
+ + + {t("applications.bulkUpdateStep3Title")} + +

{t("applications.bulkUpdateStep3Body")}

+ +
+
+
+ + + +
+ ) +} + +export default BulkUpdateDrawer diff --git a/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdIncome.tsx b/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdIncome.tsx index ec7e2ddca70..1f8e4a87190 100644 --- a/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdIncome.tsx +++ b/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdIncome.tsx @@ -6,9 +6,34 @@ import { formatIncome } from "../../../../lib/helpers" import SectionWithGrid from "../../../shared/SectionWithGrid" import { IncomePeriodEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" -const DetailsHouseholdIncome = () => { +type DetailsHouseholdIncomeProps = { + enableMultiselectVoucherQuestion?: boolean +} + +const DetailsHouseholdIncome = ({ + enableMultiselectVoucherQuestion, +}: DetailsHouseholdIncomeProps) => { const application = useContext(ApplicationContext) + const renderVouchers = () => { + if (enableMultiselectVoucherQuestion) { + if (!application.incomeVouchers || application.incomeVouchers.length === 0) { + return t("t.n/a") + } + return application.incomeVouchers + .map((v) => t(`application.financial.vouchers.${v}`)) + .join(", ") + } + if (application.incomeVouchers === null || application.incomeVouchers === undefined) { + return t("t.n/a") + } + return application.incomeVouchers?.includes("incomeVoucher") + ? t("t.yes") + : application.incomeVouchers?.includes("none") + ? t("t.no") + : t("t.n/a") + } + return ( @@ -38,15 +63,7 @@ const DetailsHouseholdIncome = () => { - {(() => { - if (application.incomeVouchers === null) return t("t.n/a") - - if (application.incomeVouchers) { - return t("t.yes") - } - - return t("t.no") - })()} + {renderVouchers()} diff --git a/sites/partners/src/components/applications/PaperApplicationForm/PaperApplicationForm.tsx b/sites/partners/src/components/applications/PaperApplicationForm/PaperApplicationForm.tsx index a110b24cddb..5e5bdef0e10 100644 --- a/sites/partners/src/components/applications/PaperApplicationForm/PaperApplicationForm.tsx +++ b/sites/partners/src/components/applications/PaperApplicationForm/PaperApplicationForm.tsx @@ -133,6 +133,13 @@ const ApplicationForm = ({ listingId, editMode, application }: ApplicationFormPr listingDto?.jurisdictions.id ) + const enableMultiselectVoucherQuestion = doJurisdictionsHaveFeatureFlagOn( + FeatureFlagEnum.enableMultiselectVoucherQuestion, + listingDto?.jurisdictions.id + ) + + console.log("enableMultiselectVoucherQuestion", enableMultiselectVoucherQuestion) + const units = listingDto?.units const defaultValues = editMode ? mapApiToForm(application, listingDto, enableV2MSQ) : {} @@ -407,7 +414,9 @@ const ApplicationForm = ({ listingId, editMode, application }: ApplicationFormPr enableV2MSQ={enableV2MSQ} /> - + { +type FormHouseholdIncomeProps = { + enableMultiselectVoucherQuestion?: boolean +} + +const FormHouseholdIncome = ({ enableMultiselectVoucherQuestion }: FormHouseholdIncomeProps) => { const formMethods = useFormContext() // eslint-disable-next-line @typescript-eslint/unbound-method @@ -16,6 +17,8 @@ const FormHouseholdIncome = () => { const incomePeriodValue: string = watch("application.incomePeriod") + const incomeVouchersValue: string[] = watch("application.incomeVouchers") || [] + return ( <>
@@ -86,15 +89,82 @@ const FormHouseholdIncome = () => { -