diff --git a/api/prisma/seed-helpers/translation-factory.ts b/api/prisma/seed-helpers/translation-factory.ts index 3b377d3df8..f77c065707 100644 --- a/api/prisma/seed-helpers/translation-factory.ts +++ b/api/prisma/seed-helpers/translation-factory.ts @@ -154,6 +154,16 @@ const translations = (jurisdictionName?: string, language?: LanguagesEnum) => { 'Use the following code to sign in to your %{jurisdictionName} account. This code will be valid for 5 minutes. Never share this code.', singleUseCode: '%{singleUseCode}', }, + scriptRunner: { + information: + 'You previously applied to Fremont Family Apartments, but an error resulted in sending your confirmation email without your confirmation number. Your application number and confirmation number are re-sent below. Thank you for your patience.', + pleaseSave: 'Please save this email for your records.', + subject: + 'Your Application Confirmation Number for Fremont Family Apartments', + yourApplicationNumber: 'Your application number is: %{id}', + yourConfirmationNumber: + 'Your confirmation number is: %{confirmationCode}', + }, }; } else if (language === LanguagesEnum.es) { return { diff --git a/api/src/controllers/script-runner.controller.ts b/api/src/controllers/script-runner.controller.ts index 808c31b688..d4331bdbc7 100644 --- a/api/src/controllers/script-runner.controller.ts +++ b/api/src/controllers/script-runner.controller.ts @@ -15,6 +15,7 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { OptionalAuthGuard } from '../guards/optional.guard'; import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; import { DataTransferDTO } from '../dtos/script-runner/data-transfer.dto'; +import { BulkApplicationResendDTO } from '../dtos/script-runner/bulk-application-resend.dto'; @Controller('scriptRunner') @ApiTags('scriptRunner') @@ -45,4 +46,21 @@ export class ScirptRunnerController { ): Promise { return await this.scriptRunnerService.dataTransfer(req, dataTransferDTO); } + + @Put('bulkApplicationResend') + @ApiOperation({ + summary: + 'A script that resends application confirmations to applicants of a listing', + operationId: 'bulkApplicationResend', + }) + @ApiOkResponse({ type: SuccessDTO }) + async bulkApplicationResend( + @Body() bulkApplicationResendDTO: BulkApplicationResendDTO, + @Request() req: ExpressRequest, + ): Promise { + return await this.scriptRunnerService.bulkApplicationResend( + req, + bulkApplicationResendDTO, + ); + } } diff --git a/api/src/dtos/script-runner/bulk-application-resend.dto.ts b/api/src/dtos/script-runner/bulk-application-resend.dto.ts new file mode 100644 index 0000000000..b2504cccda --- /dev/null +++ b/api/src/dtos/script-runner/bulk-application-resend.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, IsString, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class BulkApplicationResendDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + listingId: string; +} diff --git a/api/src/modules/script-runner.module.ts b/api/src/modules/script-runner.module.ts index dee4a6e945..eb2c5ff25a 100644 --- a/api/src/modules/script-runner.module.ts +++ b/api/src/modules/script-runner.module.ts @@ -3,9 +3,10 @@ import { ScirptRunnerController } from '../controllers/script-runner.controller' import { ScriptRunnerService } from '../services/script-runner.service'; import { PrismaModule } from './prisma.module'; import { PermissionModule } from './permission.module'; +import { EmailModule } from './email.module'; @Module({ - imports: [PrismaModule, PermissionModule], + imports: [PrismaModule, PermissionModule, EmailModule], controllers: [ScirptRunnerController], providers: [ScriptRunnerService], exports: [ScriptRunnerService], diff --git a/api/src/services/script-runner.service.ts b/api/src/services/script-runner.service.ts index 0d34256194..be15215430 100644 --- a/api/src/services/script-runner.service.ts +++ b/api/src/services/script-runner.service.ts @@ -6,6 +6,9 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { User } from '../dtos/users/user.dto'; import { mapTo } from '../utilities/mapTo'; import { DataTransferDTO } from '../dtos/script-runner/data-transfer.dto'; +import { BulkApplicationResendDTO } from '../dtos/script-runner/bulk-application-resend.dto'; +import { EmailService } from './email.service'; +import { Application } from '../dtos/applications/application.dto'; /** this is the service for running scripts @@ -13,7 +16,10 @@ import { DataTransferDTO } from '../dtos/script-runner/data-transfer.dto'; */ @Injectable() export class ScriptRunnerService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private emailService: EmailService, + ) {} /** * @@ -53,6 +59,81 @@ export class ScriptRunnerService { return { success: true }; } + /** + * + * @param req incoming request object + * @param bulkApplicationResendDTO bulk resend arg. Should contain listing id + * @returns successDTO + * @description resends a confirmation email to all applicants on a listing with an email + */ + async bulkApplicationResend( + req: ExpressRequest, + bulkApplicationResendDTO: BulkApplicationResendDTO, + ): Promise { + // script runner standard start up + const requestingUser = mapTo(User, req['user']); + await this.markScriptAsRunStart('bulk application resend', requestingUser); + + // gather listing data + const listing = await this.prisma.listings.findUnique({ + select: { + id: true, + jurisdictions: { + select: { + id: true, + }, + }, + }, + where: { + id: bulkApplicationResendDTO.listingId, + }, + }); + + if (!listing || !listing.jurisdictions) { + throw new BadRequestException('Listing does not exist'); + } + + // gather up all applications for that listing + const rawApplications = await this.prisma.applications.findMany({ + select: { + id: true, + language: true, + confirmationCode: true, + applicant: { + select: { + id: true, + emailAddress: true, + firstName: true, + middleName: true, + lastName: true, + }, + }, + }, + where: { + listingId: bulkApplicationResendDTO.listingId, + deletedAt: null, + applicant: { + emailAddress: { + not: null, + }, + }, + }, + }); + const applications = mapTo(Application, rawApplications); + + // send emails + for (const application of applications) { + await this.emailService.applicationScriptRunner( + mapTo(Application, application), + { id: listing.jurisdictions.id }, + ); + } + + // script runner standard spin down + await this.markScriptAsComplete('bulk application resend', requestingUser); + return { success: true }; + } + /** this is simply an example */ diff --git a/api/test/unit/services/script-runner.service.spec.ts b/api/test/unit/services/script-runner.service.spec.ts index 76bce2db7d..4f367b5332 100644 --- a/api/test/unit/services/script-runner.service.spec.ts +++ b/api/test/unit/services/script-runner.service.spec.ts @@ -1,26 +1,32 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { Logger } from '@nestjs/common'; -import { SchedulerRegistry } from '@nestjs/schedule'; import { randomUUID } from 'crypto'; import { Request as ExpressRequest } from 'express'; import { ScriptRunnerService } from '../../../src/services/script-runner.service'; import { PrismaService } from '../../../src/services/prisma.service'; import { User } from '../../../src/dtos/users/user.dto'; +import { EmailService } from '../../../src/services/email.service'; describe('Testing script runner service', () => { let service: ScriptRunnerService; let prisma: PrismaService; + let emailService: EmailService; + beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ScriptRunnerService, PrismaService, - Logger, - SchedulerRegistry, + { + provide: EmailService, + useValue: { + applicationScriptRunner: jest.fn(), + }, + }, ], }).compile(); service = module.get(ScriptRunnerService); + emailService = module.get(EmailService); prisma = module.get(PrismaService); }); @@ -67,6 +73,125 @@ describe('Testing script runner service', () => { }); }); + it('should bulk resend application confirmations', async () => { + const id = randomUUID(); + const scriptName = 'bulk application resend'; + const listingId = randomUUID(); + const jurisdictionId = randomUUID(); + const applicationId = randomUUID(); + + prisma.scriptRuns.findUnique = jest.fn().mockResolvedValue(null); + prisma.scriptRuns.create = jest.fn().mockResolvedValue(null); + prisma.scriptRuns.update = jest.fn().mockResolvedValue(null); + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: listingId, + jurisdictions: { + id: jurisdictionId, + }, + }); + prisma.applications.findMany = jest.fn().mockResolvedValue([ + { + id: applicationId, + language: 'en', + confirmationCode: 'conf code', + applicant: { + id: randomUUID(), + emailAddress: 'example email address', + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + }, + }, + ]); + + const res = await service.bulkApplicationResend( + { + user: { + id, + } as unknown as User, + } as unknown as ExpressRequest, + { + listingId, + }, + ); + + expect(res.success).toBe(true); + + expect(prisma.scriptRuns.findUnique).toHaveBeenCalledWith({ + where: { + scriptName, + }, + }); + expect(prisma.scriptRuns.create).toHaveBeenCalledWith({ + data: { + scriptName, + triggeringUser: id, + }, + }); + expect(prisma.scriptRuns.update).toHaveBeenCalledWith({ + data: { + didScriptRun: true, + triggeringUser: id, + }, + where: { + scriptName, + }, + }); + expect(prisma.listings.findUnique).toHaveBeenCalledWith({ + select: { + id: true, + jurisdictions: { + select: { + id: true, + }, + }, + }, + where: { + id: listingId, + }, + }); + expect(prisma.applications.findMany).toHaveBeenCalledWith({ + select: { + id: true, + language: true, + confirmationCode: true, + applicant: { + select: { + id: true, + emailAddress: true, + firstName: true, + middleName: true, + lastName: true, + }, + }, + }, + where: { + listingId: listingId, + deletedAt: null, + applicant: { + emailAddress: { + not: null, + }, + }, + }, + }); + expect(emailService.applicationScriptRunner).toHaveBeenCalledWith( + { + id: applicationId, + language: 'en', + confirmationCode: 'conf code', + applicant: { + id: expect.anything(), + emailAddress: 'example email address', + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + }, + }, + { id: jurisdictionId }, + ); + }); + // | ---------- HELPER TESTS BELOW ---------- | // it('should mark script run as started if no script run present in db', async () => { prisma.scriptRuns.findUnique = jest.fn().mockResolvedValue(null); diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index ef311c4cca..6c0902fbc7 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -2037,6 +2037,28 @@ export class ScriptRunnerService { configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * A script that resends application confirmations to applicants of a listing + */ + bulkApplicationResend( + params: { + /** requestBody */ + body?: BulkApplicationResendDTO + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/scriptRunner/bulkApplicationResend" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) }) } @@ -5226,6 +5248,11 @@ export interface DataTransferDTO { connectionString: string } +export interface BulkApplicationResendDTO { + /** */ + listingId: string +} + export enum ListingViews { "fundamentals" = "fundamentals", "base" = "base",