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
2 changes: 2 additions & 0 deletions apps/backend/src/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { UpdateFoodRequestTypesAndOrder1781476891610 } from '../migrations/17814
import { UpdatePantryFMApplicationInfo1780913024514 } from '../migrations/1780913024514-UpdatePantryFMApplicationInfo';
import { AddUserActiveField1780531200000 } from '../migrations/1780531200000-AddUserActiveField';
import { MakeDonationItemDetailsRequired1782000000000 } from '../migrations/1782000000000-MakeDonationItemDetailsRequired';
import { AddShippingCostPaidBySsf1782613537327 } from '../migrations/1782613537327-AddShippingCostPaidBySsf';

const schemaMigrations = [
User1725726359198,
Expand Down Expand Up @@ -96,6 +97,7 @@ const schemaMigrations = [
UpdatePantryFMApplicationInfo1780913024514,
AddUserActiveField1780531200000,
MakeDonationItemDetailsRequired1782000000000,
AddShippingCostPaidBySsf1782613537327,
];

export default schemaMigrations;
15 changes: 0 additions & 15 deletions apps/backend/src/donations/donations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ import {
OwnerIdResolver,
pipeNullable,
} from '../auth/ownership.decorator';
import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service';
import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity';
import { AuthenticatedRequest } from '../auth/authenticated-request';

const resolveDonationAuthorizedUserIds: OwnerIdResolver = ({
Expand All @@ -40,19 +38,6 @@ const resolveDonationAuthorizedUserIds: OwnerIdResolver = ({
],
);

// For creating a donation, the foodManufacturerId comes from the request body
// and the only authorized non-admin caller is the manufacturer representative.
const resolveCreateDonationAuthorizedUserIds: OwnerIdResolver = ({
entityId,
services,
}) =>
pipeNullable(
() => services.get(FoodManufacturersService).findOne(entityId),
(manufacturer: FoodManufacturer) => [
manufacturer.foodManufacturerRepresentative.id,
],
);

@Controller('donations')
export class DonationsController {
constructor(private donationService: DonationService) {}
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/emails/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ export const emailTemplates = {
.map((item) => `<li>${item.quantity} of ${item.product}</li>`)
.join('')}
</ul>
<p>
We strongly recommend that clients read all ingredients and allergen labels before consuming any foods.
Any questions or issues should be directed to the manufacturer(s) and/or their healthcare provider(s).
</p>
<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 in to the platform</a>.
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class DonationOrderDetailsDto {
pantryName!: string;
trackingLink!: string | null;
shippingCost!: number | null;
shippingCostPaidBySsf!: boolean;
items!: PendingOrderItemDto[];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ describe('FoodManufacturersController', () => {
pantryName: 'Community Food Pantry',
trackingLink: null,
shippingCost: null,
shippingCostPaidBySsf: false,
items: [],
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export class FoodManufacturersService {
pantryName: order.request.pantry.pantryName,
trackingLink: order.trackingLink,
shippingCost: order.shippingCost,
shippingCostPaidBySsf: order.shippingCostPaidBySsf,
items: [],
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddShippingCostPaidBySsf1782613537327
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE orders
ADD COLUMN shipping_cost_paid_by_ssf boolean NOT NULL DEFAULT false;
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE orders
DROP COLUMN shipping_cost_paid_by_ssf;
`);
}
}
5 changes: 5 additions & 0 deletions apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsOptional,
Expand Down Expand Up @@ -31,6 +32,10 @@ export class OrderTrackingCostEntryDto {
)
@Min(0, { message: 'Shipping cost cannot be negative' })
shippingCost?: number;

@IsOptional()
@IsBoolean()
shippingCostPaidBySsf?: boolean;
}

export class BulkUpdateTrackingCostDto {
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/src/orders/order.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ export class Order {
})
shippingCost!: number | null;

@Column({
name: 'shipping_cost_paid_by_ssf',
type: 'boolean',
default: false,
})
shippingCostPaidBySsf!: boolean;

@ManyToOne(() => User, { nullable: false, onDelete: 'RESTRICT' })
@JoinColumn({ name: 'assignee_id' })
assignee!: User;
Expand Down
24 changes: 24 additions & 0 deletions apps/backend/src/orders/order.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1942,6 +1942,30 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${
expect(after2.shippedAt).toBeDefined();
});

it('persists the shippingCostPaidBySsf flag when provided', async () => {
const donationId = await insertMatchedDonation();
const itemId = await insertDonationItem(donationId);
await insertAllocation(4, itemId);

const before = await service.findOne(4);
expect(before.shippingCostPaidBySsf).toBe(false);

await service.bulkUpdateTrackingCostInfo({
donationId,
orders: [
{
orderId: 4,
shippingCost: 5.0,
shippingCostPaidBySsf: true,
},
],
});

const after = await service.findOne(4);
expect(after.shippingCost).toEqual(5.0);
expect(after.shippingCostPaidBySsf).toBe(true);
});

it('updates only tracking link when no shipping cost is provided, order stays PENDING', async () => {
const donationId = await insertMatchedDonation();
const itemId = await insertDonationItem(donationId);
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/orders/order.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,9 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${
if (entry.shippingCost !== undefined) {
order.shippingCost = entry.shippingCost;
}
if (entry.shippingCostPaidBySsf !== undefined) {
order.shippingCostPaidBySsf = entry.shippingCostPaidBySsf;
}
if (order.trackingLink !== null && order.shippingCost !== null) {
order.status = OrderStatus.SHIPPED;
order.shippedAt = new Date();
Expand Down
12 changes: 9 additions & 3 deletions apps/backend/src/pantries/pantries.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,8 +467,10 @@ describe('PantriesController', () => {
totalLbs: 100,
totalDonatedFoodValue: 500,
totalShippingCost: 50,
totalShippingCostPaidBySsf: 20,
totalValue: 550,
percentageFoodRescueItems: 80,
percentageFoodRescueItems: 40,
foodRescueLbs: 80,
},
];

Expand Down Expand Up @@ -507,8 +509,10 @@ describe('PantriesController', () => {
totalLbs: 500,
totalDonatedFoodValue: 2500,
totalShippingCost: 200,
totalShippingCostPaidBySsf: 80,
totalValue: 2700,
percentageFoodRescueItems: 75,
percentageFoodRescueItems: 30,
foodRescueLbs: 75,
};

mockPantriesService.getTotalStats.mockResolvedValueOnce(mockTotalStats);
Expand All @@ -526,8 +530,10 @@ describe('PantriesController', () => {
totalLbs: 500,
totalDonatedFoodValue: 2500,
totalShippingCost: 200,
totalShippingCostPaidBySsf: 80,
totalValue: 2700,
percentageFoodRescueItems: 75,
percentageFoodRescueItems: 30,
foodRescueLbs: 75,
};

mockPantriesService.getTotalStats.mockResolvedValueOnce(mockTotalStats);
Expand Down
32 changes: 32 additions & 0 deletions apps/backend/src/pantries/pantries.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,34 @@ describe('PantriesService', () => {
expect(stats.totalDonatedFoodValue).toBeCloseTo(625.0, 2);
expect(stats.totalValue).toBeCloseTo(645.0, 2);
expect(stats.percentageFoodRescueItems).toBe(0);
expect(stats.foodRescueLbs).toBe(0);
// No orders are flagged SSF-paid in the seed data
expect(stats.totalShippingCostPaidBySsf).toBe(0);
});

it('only sums SSF-paid orders into totalShippingCostPaidBySsf', async () => {
// Capture the pantry's shipping before flagging anything as SSF-paid.
const before = (
await service.getPantryStats(['Community Food Pantry Downtown'])
)[0];
expect(before.totalShippingCost).toBeGreaterThan(0);

// Flag every order as paid by SSF. SSF-paid shipping is excluded from
// totalShippingCost, so that total should drop to zero while the SSF-paid
// total picks up the entire shipping amount.
await testDataSource.query(
`UPDATE public.orders SET shipping_cost_paid_by_ssf = true`,
);

const stats = (
await service.getPantryStats(['Community Food Pantry Downtown'])
)[0];

expect(stats.totalShippingCost).toBe(0);
expect(stats.totalShippingCostPaidBySsf).toBeCloseTo(
before.totalShippingCost,
2,
);
});

it('throws NotFoundException for a non-approved (denied) pantry', async () => {
Expand Down Expand Up @@ -567,6 +595,7 @@ describe('PantriesService', () => {
expect(stats.totalShippingCost).toBe(0);
expect(stats.totalValue).toBe(0);
expect(stats.percentageFoodRescueItems).toBe(0);
expect(stats.foodRescueLbs).toBe(0);
});

it('respects year filter and returns zeros for a non-matching year', async () => {
Expand All @@ -582,6 +611,7 @@ describe('PantriesService', () => {
expect(stats.totalShippingCost).toBe(0);
expect(stats.totalValue).toBe(0);
expect(stats.percentageFoodRescueItems).toBe(0);
expect(stats.foodRescueLbs).toBe(0);
});
});

Expand Down Expand Up @@ -817,6 +847,7 @@ describe('PantriesService', () => {
expect(totalEmpty.totalShippingCost).toBe(0);
expect(totalEmpty.totalValue).toBe(0);
expect(totalEmpty.percentageFoodRescueItems).toBe(0);
expect(totalEmpty.foodRescueLbs).toBe(0);
});

it('returns all zeros when no approved pantries exist', async () => {
Expand All @@ -831,6 +862,7 @@ describe('PantriesService', () => {
expect(total.totalShippingCost).toBe(0);
expect(total.totalValue).toBe(0);
expect(total.percentageFoodRescueItems).toBe(0);
expect(total.foodRescueLbs).toBe(0);
});

it('excludes a pantry set to pending from the totals', async () => {
Expand Down
39 changes: 32 additions & 7 deletions apps/backend/src/pantries/pantries.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,17 @@ export class PantriesService {
totalLbs: 0,
totalDonatedFoodValue: 0,
totalShippingCost: 0,
totalShippingCostPaidBySsf: 0,
totalValue: 0,
percentageFoodRescueItems: 0,
foodRescueLbs: 0,
};

private async aggregateStats(
pantryIds: number[],
years?: number[],
): Promise<Omit<PantryStats, 'pantryName'>[]> {
// Query 1: aggregate item stats (totalItems, totalOz, totalDonatedFoodValue, totalFoodRescueItems)
// Query 1: aggregate item stats (totalItems, totalOz, totalDonatedFoodValue, foodRescueItems, foodRescueOz)
const itemsQb = this.orderRepo
.createQueryBuilder('order')
.leftJoin('order.request', 'request')
Expand All @@ -91,7 +93,11 @@ export class PantriesService {
)
.addSelect(
`COALESCE(SUM(CASE WHEN item.foodRescue = true THEN allocation.allocatedQuantity ELSE 0 END), 0)`,
'totalFoodRescueItems',
'foodRescueItems',
)
.addSelect(
`COALESCE(SUM(CASE WHEN item.foodRescue = true THEN COALESCE(item.ozPerItem, 0) * allocation.allocatedQuantity ELSE 0 END), 0)`,
'foodRescueOz',
)
.where('request.pantryId IN (:...pantryIds)', { pantryIds })
.groupBy('request.pantryId');
Expand All @@ -107,7 +113,14 @@ export class PantriesService {
.createQueryBuilder('order')
.leftJoin('order.request', 'request')
.select('request.pantryId', 'pantryId')
.addSelect('COALESCE(SUM(order.shippingCost), 0)', 'totalShippingCost')
.addSelect(
`COALESCE(SUM(CASE WHEN order.shippingCostPaidBySsf = true THEN 0 ELSE order.shippingCost END), 0)`,
'totalShippingCost',
)
.addSelect(
`COALESCE(SUM(CASE WHEN order.shippingCostPaidBySsf = true THEN order.shippingCost ELSE 0 END), 0)`,
'totalShippingCostPaidBySsf',
)
.where('request.pantryId IN (:...pantryIds)', { pantryIds })
.groupBy('request.pantryId');

Expand All @@ -126,16 +139,23 @@ export class PantriesService {
const shippingMap = new Map(
shippingRows.map((r) => [
Number(r.pantryId),
Number(r.totalShippingCost),
{
totalShippingCost: Number(r.totalShippingCost),
totalShippingCostPaidBySsf: Number(r.totalShippingCostPaidBySsf),
},
]),
);

return itemRows.map((row) => {
const totalItems = Number(row.totalItems);
const totalOz = Number(row.totalOz);
const totalDonatedFoodValue = Number(row.totalDonatedFoodValue);
const totalShippingCost = shippingMap.get(Number(row.pantryId)) ?? 0;
const totalFoodRescueItems = Number(row.totalFoodRescueItems);
const shipping = shippingMap.get(Number(row.pantryId));
const totalShippingCost = shipping?.totalShippingCost ?? 0;
const totalShippingCostPaidBySsf =
shipping?.totalShippingCostPaidBySsf ?? 0;
const foodRescueItems = Number(row.foodRescueItems);
const foodRescueOz = Number(row.foodRescueOz);

return {
pantryId: Number(row.pantryId),
Expand All @@ -144,11 +164,13 @@ export class PantriesService {
totalLbs: parseFloat((totalOz / 16).toFixed(2)),
totalDonatedFoodValue,
totalShippingCost,
totalShippingCostPaidBySsf,
totalValue: totalDonatedFoodValue + totalShippingCost,
percentageFoodRescueItems:
totalItems > 0
? parseFloat(((totalFoodRescueItems / totalItems) * 100).toFixed(2))
? parseFloat(((foodRescueItems / totalItems) * 100).toFixed(2))
: 0,
foodRescueLbs: parseFloat((foodRescueOz / 16).toFixed(2)),
} satisfies Omit<PantryStats, 'pantryName'>;
});
}
Expand Down Expand Up @@ -242,12 +264,15 @@ export class PantriesService {
totalStats.totalOz += s.totalOz;
totalStats.totalDonatedFoodValue += s.totalDonatedFoodValue;
totalStats.totalShippingCost += s.totalShippingCost;
totalStats.totalShippingCostPaidBySsf += s.totalShippingCostPaidBySsf;
totalStats.totalValue += s.totalValue;
totalStats.foodRescueLbs += s.foodRescueLbs;
totalFoodRescueItems +=
(s.percentageFoodRescueItems / 100) * s.totalItems;
}

totalStats.totalLbs = parseFloat((totalStats.totalOz / 16).toFixed(2));
totalStats.foodRescueLbs = parseFloat(totalStats.foodRescueLbs.toFixed(2));
totalStats.percentageFoodRescueItems =
totalStats.totalItems > 0
? parseFloat(
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/pantries/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ export type PantryStats = {
totalLbs: number;
totalDonatedFoodValue: number;
totalShippingCost: number;
totalShippingCostPaidBySsf: number;
totalValue: number;
percentageFoodRescueItems: number;
foodRescueLbs: number;
};

// Make new type that is just a list of PantryStats with pantryId and pantryName omitted
Expand Down
Loading
Loading