Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 122 additions & 3 deletions apps/backend/src/applications/applications.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { ArgumentsHost, BadRequestException } from '@nestjs/common';
import { ApplicationsController } from './applications.controller';
import { ApplicationsService } from './applications.service';
import { Application } from './application.entity';
Expand All @@ -10,6 +10,13 @@ import {
ApplicantType,
} from './types';
import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants';
import { EmailService } from '../util/email/email.service';
import { ApplicationValidationEmailFilter } from './filters/application-validation-email.filter';
import { ApplicationCreationErrorFilter } from './filters/application-creation-validation.filter';

const mockEmailService = {
queueEmail: jest.fn().mockResolvedValue(undefined),
};

const mockApplicationsService: Partial<ApplicationsService> = {
findAll: jest.fn(),
Expand Down Expand Up @@ -59,21 +66,41 @@ const mockApplication: Application = {
endDate: new Date('2024-06-30'),
};

function createMockHttpHost(body: Record<string, unknown> | undefined) {
const json = jest.fn();
const status = jest.fn().mockReturnValue({ json });
const response = { status };
const request = { body };
const host = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => response,
}),
} as ArgumentsHost;
return { host, json, status };
}

describe('ApplicationsController', () => {
let controller: ApplicationsController;
let testingModule: TestingModule;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
testingModule = await Test.createTestingModule({
controllers: [ApplicationsController],
providers: [
{
provide: ApplicationsService,
useValue: mockApplicationsService,
},
{ provide: EmailService, useValue: mockEmailService },
ApplicationValidationEmailFilter,
ApplicationCreationErrorFilter,
],
}).compile();

controller = module.get<ApplicationsController>(ApplicationsController);
controller = testingModule.get<ApplicationsController>(
ApplicationsController,
);
});

afterEach(() => {
Expand All @@ -84,6 +111,98 @@ describe('ApplicationsController', () => {
expect(controller).toBeDefined();
});

describe('createApplication exception filters', () => {
let validationEmailFilter: ApplicationValidationEmailFilter;
let creationErrorFilter: ApplicationCreationErrorFilter;

beforeEach(() => {
validationEmailFilter = testingModule.get(
ApplicationValidationEmailFilter,
);
creationErrorFilter = testingModule.get(ApplicationCreationErrorFilter);
});

it('ApplicationValidationEmailFilter sends email and returns 400 body when ValidationPipe-style BadRequestException is caught', async () => {
const payload = {
email: 'applicant@example.com',
appStatus: 'invalid',
};
const { host, json, status } = createMockHttpHost(payload);
const exceptionResponse = {
message: ['appStatus must be one of the following values: x'],
error: 'Bad Request',
statusCode: 400,
};
const exception = new BadRequestException(exceptionResponse);

await validationEmailFilter.catch(exception, host);

expect(mockEmailService.queueEmail).toHaveBeenCalledTimes(1);
expect(mockEmailService.queueEmail).toHaveBeenCalledWith(
'applicant@example.com',
'Action Required: Issue with Your Application Submission',
expect.stringContaining('Hello Applicant'),
);
expect(mockEmailService.queueEmail).toHaveBeenCalledWith(
'applicant@example.com',
'Action Required: Issue with Your Application Submission',
expect.stringContaining('Application Status'),
);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith(exceptionResponse);
});

it('ApplicationValidationEmailFilter skips email when request body has no email', async () => {
const { host, json, status } = createMockHttpHost({ appStatus: 'bad' });
const exceptionResponse = {
message: ['appStatus must be valid'],
error: 'Bad Request',
statusCode: 400,
};
const exception = new BadRequestException(exceptionResponse);

await validationEmailFilter.catch(exception, host);

expect(mockEmailService.queueEmail).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith(exceptionResponse);
});

it('ApplicationCreationErrorFilter sends generic email and returns 500 for non-BadRequest errors', async () => {
const payload = { email: 'applicant@example.com' };
const { host, json, status } = createMockHttpHost(payload);

await creationErrorFilter.catch(new Error('database unavailable'), host);

expect(mockEmailService.queueEmail).toHaveBeenCalledTimes(1);
expect(mockEmailService.queueEmail).toHaveBeenCalledWith(
'applicant@example.com',
'Issue with Your Application Submission',
expect.stringContaining('unexpected issue'),
);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({
message:
'An unexpected error occurred while processing your application.',
statusCode: 500,
});
});

it('ApplicationCreationErrorFilter skips email when body has no email', async () => {
const { host, json, status } = createMockHttpHost({});

await creationErrorFilter.catch(new Error('fail'), host);

expect(mockEmailService.queueEmail).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({
message:
'An unexpected error occurred while processing your application.',
statusCode: 500,
});
});
});

describe('count endpoints', () => {
it('should return total applications count', async () => {
jest.spyOn(mockApplicationsService, 'countAll').mockResolvedValue(298);
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/applications/applications.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Patch,
Post,
Query,
UseFilters,
} from '@nestjs/common';
import { ApplicationsService } from './applications.service';
import { Application } from './application.entity';
Expand All @@ -16,6 +17,8 @@ import { ApiTags } from '@nestjs/swagger';
import { UpdateApplicationStatusDto } from './dto/update-application-status.request.dto';
import { UpdateApplicationDisciplineDto } from './dto/update-application-discipline.request.dto';
import { UpdateApplicationAvailabilityDto } from './dto/update-application-availability.request.dto';
import { ApplicationValidationEmailFilter } from './filters/application-validation-email.filter';
import { ApplicationCreationErrorFilter } from './filters/application-creation-validation.filter';

/**
* Controller to expose HTTP endpoints to interface, extract, and change information about the app's applications.
Expand Down Expand Up @@ -115,6 +118,7 @@ export class ApplicationsController {
* @throws {Error} which is unchanged from what repository throws.
*/
@Post()
@UseFilters(ApplicationCreationErrorFilter, ApplicationValidationEmailFilter)
async createApplication(
@Body() createApplicationDto: CreateApplicationDto,
): Promise<Application> {
Expand Down
11 changes: 9 additions & 2 deletions apps/backend/src/applications/applications.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationsController } from './applications.controller';
import { ApplicationsService } from './applications.service';
import { Application } from './application.entity';
import { UtilModule } from '../util/util.module';
import { ApplicationValidationEmailFilter } from './filters/application-validation-email.filter';
import { ApplicationCreationErrorFilter } from './filters/application-creation-validation.filter';

@Module({
imports: [TypeOrmModule.forFeature([Application])],
imports: [TypeOrmModule.forFeature([Application]), UtilModule],
controllers: [ApplicationsController],
providers: [ApplicationsService],
providers: [
ApplicationsService,
ApplicationValidationEmailFilter,
ApplicationCreationErrorFilter,
],
})
export class ApplicationsModule {}
50 changes: 50 additions & 0 deletions apps/backend/src/applications/applications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,22 @@
import { CreateApplicationDto } from './dto/create-application.request.dto';
import { AppStatus, PHONE_REGEX } from './types';
import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants';
import { EmailService } from '../util/email/email.service';

Check warning on line 12 in apps/backend/src/applications/applications.service.ts

View workflow job for this annotation

GitHub Actions / build

'EmailService' is defined but never used
import { UsersService } from '../users/users.service';

Check warning on line 13 in apps/backend/src/applications/applications.service.ts

View workflow job for this annotation

GitHub Actions / build

'UsersService' is defined but never used

/**
* Escapes characters that have special meaning in HTML so a string is safe to embed in text or attributes.
*
* @param text Raw string that may contain `&`, `<`, `>`, or `"`.
* @returns The same content with those characters replaced by entity references (`&amp;`, `&lt;`, `&gt;`, `&quot;`).
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Service for applications that interfaces with the application repository.
*/
Expand Down Expand Up @@ -287,4 +302,39 @@
}
await this.applicationRepository.remove(application);
}

/**
* Builds the HTML email body for a failed application submission.
* Uses data directly from the DTO — no database lookup required.
*
* @param applicantDto The submitted application data.
* @param errorMessage The sanitized validation error message.
* @param pandaDocLink The URL to the PandaDoc resubmission form.
* @returns The formatted HTML email body string.
*/
private buildApplicationSubmissionErrorEmailBody(
applicantName: string,
applicantDto: CreateApplicationDto,
errorMessage: string,
pandaDocLink: string,
): string {
const submittedFields = escapeHtml(JSON.stringify(applicantDto, null, 2));

const linkBlock = pandaDocLink
? `<p><a href="${escapeHtml(
pandaDocLink,
)}">Click here to resubmit your application</a></p>`
: '';

return `<p>Hello ${applicantName},</p>
<p>We were unable to process your application due to an issue with the information provided.</p>
<p><strong>What needs to be corrected:</strong></p>
<p>${escapeHtml(errorMessage)}</p>
<p><strong>Your submitted information:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;">${submittedFields}</pre>
<p>Please review the information above and resubmit your application through the PandaDoc form with the correct details.</p>
${linkBlock}
<p>We appreciate your time and apologize for the inconvenience.</p>
<p>Best regards,<br/>Boston Health Care for the Homeless Program</p>`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ExceptionFilter, Catch, ArgumentsHost, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { EmailService } from '../../util/email/email.service';

/**
* Exception filter that sends an error notification email when
* `POST /api/applications` fails after the body is parsed.
*
* Handles all other exceptions and sends an email to the
* applicant if the request body includes an `email` recipient.
*/
@Catch(Error)
export class ApplicationCreationErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(ApplicationCreationErrorFilter.name);

constructor(private readonly emailService: EmailService) {}

/**
* Nest entrypoint: sends an error notification email to the applicant if the request body includes an `email` recipient.
*
* @param exception The exception that was thrown.
* @param host Nest execution context; used to read the HTTP request and write the response.
* @returns Resolves after the response is sent (email failures are logged and do not change the HTTP outcome).
*/
async catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();

// BadRequestException is handled by ApplicationValidationEmailFilter (see controller @UseFilters order + Nest's filter reversal).
try {
const body = request.body as Record<string, unknown> | undefined;
const recipientEmail = body?.email;

if (recipientEmail) {
await this.emailService.queueEmail(
String(recipientEmail),
'Issue with Your Application Submission',
`<p>Hello,</p>
<p>We encountered an unexpected issue while processing your application.
Your submission was <strong>not</strong> saved.</p>
<p>Please try submitting your application again. If the issue persists,
contact our team for assistance.</p>
<p>We apologize for the inconvenience.</p>
<p>Best regards,<br/>Boston Health Care for the Homeless Program</p>`,
);
this.logger.log(`Creation error email sent to ${recipientEmail}`);
}
} catch (emailError) {
this.logger.error(
'Failed to send creation error email',
emailError instanceof Error ? emailError.stack : emailError,
);
}

// Return generic 500
response.status(500).json({
message:
'An unexpected error occurred while processing your application.',
statusCode: 500,
});
}
}
Loading
Loading