Skip to content
Open
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
111 changes: 110 additions & 1 deletion apps/backend/src/emails/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type EmailTemplate = {
additionalContent?: string;
};

export const EMAIL_REDIRECT_URL = 'localhost:4200';
export const EMAIL_REDIRECT_URL = 'https://localhost:4200';
// TODO: Change this before production to be the actual ssf email
export const SSF_PARTNER_EMAIL = 'example@gmail.com';

Expand Down Expand Up @@ -98,4 +98,113 @@ export const emailTemplates = {
<p>Best regards,<br />The Securing Safe Food Team</p>
`,
}),

pantryRequestMatchedOrder: (params: {
pantryName: string;
items: { quantity: string; product: string }[];
brand: string;
volunteerName: string;
volunteerEmail: string;
}): EmailTemplate => ({
subject: 'Your Securing Safe Food Request Has Been Matched to a Delivery',
bodyHTML: `
<p>Hi ${params.pantryName},</p>
<p>
Good news! Your recent food request through Securing Safe Food has been successfully matched to an order and is now moving forward toward delivery.
</p>
<p><strong>Items you will receive from ${params.brand}:</strong></p>
<ul>
${params.items
.map((item) => `<li>${item.quantity} of ${item.product}</li>`)
.join('')}
</ul>
<p>
To view full order details, delivery updates, and any notes from the coordinating volunteer or food manufacturer, please <a href="${EMAIL_REDIRECT_URL}/login">log into the platform</a>.
</p>
<p>
If any details change on your end or you have updated availability, please update your request in the system or email your coordinator, ${
params.volunteerName
} at <a href="mailto:${params.volunteerEmail}">${
params.volunteerEmail
}</a>.
</p>
<p>
We will continue to keep you informed as the order progresses. We’re excited to help support your pantry and looking forward to this donation!
</p>
<p>Best regards,<br />The Securing Safe Food Team</p>
`,
}),

pantryRequestClosed: (params: {
pantryName: string;
volunteerName: string;
volunteerEmail: string;
}): EmailTemplate => ({
subject: 'Your Securing Safe Food Request Has Been Completed',
bodyHTML: `
<p>Hi ${params.pantryName},</p>
<p>
Your recent food request through Securing Safe Food has been marked as complete.
We are glad to fulfill your pantry's requests! If you would like to continue receiving
donations, please submit a new food request at any time to ensure there is no interruption
in future deliveries.
</p>
<p>
To submit a new request or view past orders, please log into the platform here:
<a href="${EMAIL_REDIRECT_URL}/login">${EMAIL_REDIRECT_URL}/login</a>
</p>
<p>
If you have any questions or feedback about this order, please do not hesitate to reach out.
You can contact your pantry coordinator, ${params.volunteerName}, at
<a href="mailto:${params.volunteerEmail}">${params.volunteerEmail}</a>.
</p>
<p>Best regards,<br />The Securing Safe Food Team</p>
`,
}),

fmDonationMatchedOrder: (params: {
manufacturerName: string;
items: { quantity: string; product: string }[];
pantryName: string;
pantryAddress: string;
volunteerName: string;
volunteerEmail: string;
}): EmailTemplate => ({
subject:
'Your Securing Safe Food Donation Has Been Matched to a Pantry Order',
bodyHTML: `
<p>Hi ${params.manufacturerName},</p>
<p>
Thank you for your continued partnership with Securing Safe Food. A donation you submitted has now been successfully matched to a pantry request and is moving forward towards fulfillment.
</p>
<p><strong>Matched Items:</strong><br /></p>
<ul>
${params.items
.map((item) => `<li>${item.quantity} of ${item.product}</li>`)
.join('')}
</ul>
<p>
<strong>Recipient Pantry:</strong> ${params.pantryName}<br />
<strong>Address:</strong><br />
${params.pantryAddress}
</p>
<p>
Please <a href="${EMAIL_REDIRECT_URL}/login">log into the platform</a> to review the full delivery details, timelines, and any special handling instructions associated with this shipment.
</p>
<p>
Your support plays a direct role in expanding access to allergen-safe foods, and we truly appreciate your commitment to this work.
</p>
<p>
If you have any questions or need assistance, please contact your coordinator, ${
params.volunteerName
} at <a href="mailto:${params.volunteerEmail}">${
params.volunteerEmail
}</a>.
</p>
<p>
Thank you so much.
</p>
<p>Best regards,<br />The Securing Safe Food Team</p>
`,
}),
};
13 changes: 11 additions & 2 deletions apps/backend/src/foodRequests/request.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from './dtos/matching.dto';
import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity';
import { Pantry } from '../pantries/pantries.entity';
import { AuthenticatedRequest } from '../auth/authenticated-request';

const mockRequestsService = mock<RequestsService>();

Expand Down Expand Up @@ -309,10 +310,18 @@ describe('RequestsController', () => {
closedRequest as FoodRequest,
);

const result = await controller.closeRequest(requestId);
const req = { user: { id: 1 } };

const result = await controller.closeRequest(
requestId,
req as AuthenticatedRequest,
);

expect(result).toEqual(closedRequest);
expect(mockRequestsService.closeRequest).toHaveBeenCalledWith(requestId);
expect(mockRequestsService.closeRequest).toHaveBeenCalledWith(
requestId,
1,
);
});
});
});
5 changes: 4 additions & 1 deletion apps/backend/src/foodRequests/request.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
ValidationPipe,
Patch,
Delete,
Req,
} from '@nestjs/common';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { ApiBody } from '@nestjs/swagger';
import { RequestsService } from './request.service';
import { FoodRequest } from './request.entity';
Expand Down Expand Up @@ -124,7 +126,8 @@ export class RequestsController {
@Patch('/:requestId/close')
async closeRequest(
@Param('requestId', ParseIntPipe) requestId: number,
@Req() req: AuthenticatedRequest,
): Promise<FoodRequest> {
return this.requestsService.closeRequest(requestId);
return this.requestsService.closeRequest(requestId, req.user.id);
}
}
4 changes: 4 additions & 0 deletions apps/backend/src/foodRequests/request.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Pantry } from '../pantries/pantries.entity';
import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity';
import { DonationItem } from '../donationItems/donationItems.entity';
import { EmailsModule } from '../emails/email.module';
import { User } from '../users/users.entity';
import { UsersModule } from '../users/users.module';

@Module({
imports: [
Expand All @@ -18,9 +20,11 @@ import { EmailsModule } from '../emails/email.module';
Pantry,
FoodManufacturer,
DonationItem,
User,
]),
AuthModule,
EmailsModule,
UsersModule,
],
controllers: [RequestsController],
providers: [RequestsService],
Expand Down
138 changes: 132 additions & 6 deletions apps/backend/src/foodRequests/request.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import { mock } from 'jest-mock-extended';
import { emailTemplates } from '../emails/emailTemplates';
import { Allocation } from '../allocations/allocations.entity';
import { ApplicationStatus } from '../shared/types';
import { User } from '../users/users.entity';
import { UsersService } from '../users/users.service';
import { Donation } from '../donations/donations.entity';
import { AuthService } from '../auth/auth.service';
import { PantriesService } from '../pantries/pantries.service';
import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service';

jest.setTimeout(60000);

Expand All @@ -38,6 +44,9 @@ describe('RequestsService', () => {
const module = await Test.createTestingModule({
providers: [
RequestsService,
UsersService,
PantriesService,
FoodManufacturersService,
{
provide: getRepositoryToken(FoodRequest),
useValue: testDataSource.getRepository(FoodRequest),
Expand All @@ -62,6 +71,20 @@ describe('RequestsService', () => {
provide: getRepositoryToken(Allocation),
useValue: testDataSource.getRepository(Allocation),
},
{
provide: getRepositoryToken(User),
useValue: testDataSource.getRepository(User),
},
{
provide: getRepositoryToken(Donation),
useValue: testDataSource.getRepository(Donation),
},
{
provide: AuthService,
useValue: {
adminCreateUser: jest.fn().mockResolvedValue('test-sub'),
},
},
{
provide: EmailsService,
useValue: mockEmailsService,
Expand Down Expand Up @@ -381,6 +404,64 @@ describe('RequestsService', () => {
new NotFoundException('Request 999 not found'),
);
});

it('sends pantry closed email with last delivered order assignee on auto-close', async () => {
const requestId = 1;
const pantry = await testDataSource.getRepository(Pantry).findOne({
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we cast both of these rather than a !

where: { pantryId: 1 },
relations: ['pantryUser'],
});
const lastDeliveredOrder = await testDataSource
.getRepository(Order)
.findOne({
where: { requestId, status: OrderStatus.DELIVERED },
order: { deliveredAt: 'DESC' },
relations: ['assignee'],
});

await service.updateRequestStatus(requestId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing here, can we check that the request is still active?


const assignee = lastDeliveredOrder!.assignee;
const expectedMessage = emailTemplates.pantryRequestClosed({
pantryName: pantry!.pantryName,
volunteerName: `${assignee.firstName} ${assignee.lastName}`,
volunteerEmail: assignee.email,
});

expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
[pantry!.pantryUser.email],
expectedMessage.subject,
expectedMessage.bodyHTML,
);
});

it('does not send email when not all orders are delivered (request stays active)', async () => {
await service.updateRequestStatus(3);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we check beforehand that one of the orders for this are not delivered (its okay to hardcode, we just want to be sure so we know thats why an email does not send).


expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
});

it('does not send email when request was already closed before updateRequestStatus', async () => {
await testDataSource.query(
`UPDATE food_requests SET status = 'closed' WHERE request_id = 1`,
);

await service.updateRequestStatus(1);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make sure the request was closed before this call?


expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
});

it('still auto-closes request when email fails', async () => {
mockEmailsService.sendEmails.mockRejectedValueOnce(
new Error('SMTP error'),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make sure before this update call, that the request beforehand was marked as active?

await service.updateRequestStatus(1);

const request = await service.findOne(1);
expect(request.status).toBe(FoodRequestStatus.CLOSED);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we either add to this test or create another test that, in this event, in addition to the request still closing, that the error message is also logged?

});
});

describe('getMatchingManufacturers', () => {
Expand Down Expand Up @@ -746,8 +827,14 @@ describe('RequestsService', () => {
});

describe('closeRequest', () => {
let volunteerId: number;

beforeEach(() => {
volunteerId = 6;
});

it('should close an active request', async () => {
const result = await service.closeRequest(3);
const result = await service.closeRequest(3, volunteerId);

expect(result.status).toBe(FoodRequestStatus.CLOSED);

Expand All @@ -756,15 +843,15 @@ describe('RequestsService', () => {
});

it('should throw BadRequestException when request is already closed', async () => {
await service.closeRequest(3);
await service.closeRequest(3, volunteerId);

await expect(service.closeRequest(3)).rejects.toThrow(
await expect(service.closeRequest(3, volunteerId)).rejects.toThrow(
new BadRequestException('Cannot close a request with status: closed'),
);
});

it('should throw NotFoundException for non-existent request', async () => {
await expect(service.closeRequest(999)).rejects.toThrow(
await expect(service.closeRequest(999, volunteerId)).rejects.toThrow(
new NotFoundException('Request 999 not found'),
);
});
Expand All @@ -774,7 +861,7 @@ describe('RequestsService', () => {
.getRepository(Order)
.find({ where: { requestId: 3 } });

await service.closeRequest(3);
await service.closeRequest(3, volunteerId);

const ordersAfter = await testDataSource
.getRepository(Order)
Expand All @@ -786,11 +873,50 @@ describe('RequestsService', () => {
});

it('should not reopen a closed request when updateRequestStatus is called', async () => {
await service.closeRequest(1);
await service.closeRequest(1, volunteerId);
await service.updateRequestStatus(1);

const fromDb = await service.findOne(1);
expect(fromDb.status).toBe(FoodRequestStatus.CLOSED);
});

it('sends pantry closed email with acting volunteer info on successful close', async () => {
const pantry = await testDataSource.getRepository(Pantry).findOne({
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: cast here

where: { pantryId: 3 },
relations: ['pantryUser'],
});

await service.closeRequest(3, volunteerId);

const expectedMessage = emailTemplates.pantryRequestClosed({
pantryName: pantry!.pantryName,
volunteerName: `James Thomas`,
volunteerEmail: `james.t@volunteer.org`,
});

expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
[pantry!.pantryUser.email],
expectedMessage.subject,
expectedMessage.bodyHTML,
);
});

it('still closes request when email fails (manual close)', async () => {
mockEmailsService.sendEmails.mockRejectedValueOnce(
new Error('SMTP error'),
);

await expect(service.closeRequest(3, volunteerId)).rejects.toThrow(
new InternalServerErrorException(
'Failed to send food request closed email to pantry',
),
);

const request = await service.findOne(3);

expect(request.status).toBe(FoodRequestStatus.CLOSED);
expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
});
});
});
Loading
Loading