Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
110 changes: 110 additions & 0 deletions scripts/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,116 @@ async function buildSeedData() {
console.log(` - Distribution Lead: ${distributionLead.email}`);
console.log(` - Partner 1: ${partner1.email}`);
console.log(` - Partner 2: ${partner2.email}`);

// ============================================================================
// Test: partner has an existing manual wishlist entry for an item,
// then a distribution only partially fulfills their request for that same item.
// Expected: existing wishlist entry quantity increases by the unfulfilled amount.
//
// Partner 1 manually added "Gauze Pads" to their wishlist (qty: 50) — this
// simulates a wishlist entry they created themselves before any donor offer.
// A donor offer comes in for Gauze Pads, Partner 1 requests 200.
// Distribution only allocates 1 line item (75 units) → unfulfilled = 125.
// Expected final wishlist qty: 50 + 125 = 175
// ============================================================================

// Step 1: Partner 1 manually creates a wishlist entry beforehand
await db.wishlist.create({
data: {
name: "Gauze Pads",
quantity: 50,
priority: "MEDIUM",
comments: "We are always in need of these",
partnerId: partner1.id,
},
});

console.log("✓ Created manual wishlist entry for Partner 1: Gauze Pads qty 50");

// Step 2: Donor offer comes in with Gauze Pads
const gauzeOffer = await db.donorOffer.create({
data: {
offerName: "Gauze Pads Offer",
donorName: "Test Donor",
state: "FINALIZED",
partnerResponseDeadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
donorResponseDeadline: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
partnerVisibilities: {
connect: [{ id: partner1.id }],
},
},
});

const gauzeItem = await db.generalItem.create({
data: {
donorOfferId: gauzeOffer.id,
title: "Gauze Pads",
unitType: "Box",
initialQuantity: 200,
weight: 0.3,
},
});

// Two line items: 75 units and 125 units
const gauzeLineItem1 = await db.lineItem.create({
data: {
generalItemId: gauzeItem.id,
donorName: "Test Donor",
quantity: 75,
lotNumber: "GLOT-1",
palletNumber: "GPAL-1",
boxNumber: "GBOX-1",
unitPrice: 3.0,
allowAllocations: true,
visible: true,
gik: true,
},
});

await db.lineItem.create({
data: {
generalItemId: gauzeItem.id,
donorName: "Test Donor",
quantity: 125,
lotNumber: "GLOT-2",
palletNumber: "GPAL-1",
boxNumber: "GBOX-2",
unitPrice: 3.0,
allowAllocations: true,
visible: true,
gik: true,
},
});

// Step 3: Partner 1 requests 200 units
await db.generalItemRequest.create({
data: {
generalItemId: gauzeItem.id,
partnerId: partner1.id,
quantity: 200,
priority: "MEDIUM",
finalQuantity: 200,
comments: "Requested from donor offer",
},
});

// Step 4: Distribution created — only allocates the 75 unit line item
const gauzeDist = await db.distribution.create({
data: {
partnerId: partner1.id,
pending: true,
allocations: {
create: [{ lineItemId: gauzeLineItem1.id, partnerId: partner1.id }],
},
},
});

console.log("✓ Created Gauze Pads distribution test");
console.log(` - Distribution ID: ${gauzeDist.id} (pending)`);
console.log(` - Partner 1 wishlist BEFORE approval: Gauze Pads qty 50`);
console.log(` - After approving dist ${gauzeDist.id}:`);
console.log(` unfulfilled = 200 - 75 = 125`);
console.log(` merged wishlist qty = 50 + 125 = 175`);
}

buildSeedData()
Expand Down
93 changes: 85 additions & 8 deletions src/services/distributionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Prisma, ShipmentStatus } from "@prisma/client";
import { Filters } from "@/types/api/filter.types";
import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table";
import { hasShippingIdentifier } from "@/util/shipping";
import { WishlistService } from "./wishlistService";

export default class DistributionService {
static async getAllDistributions(
Expand Down Expand Up @@ -367,9 +368,6 @@ export default class DistributionService {

for (const distribution of distributions) {
for (const allocation of distribution.allocations) {
// const item = await db.item.findUnique({
// where: { id: allocation.itemId },
// });
const item = await db.lineItem.findUnique({
where: { id: allocation.lineItemId },
});
Expand Down Expand Up @@ -672,16 +670,95 @@ export default class DistributionService {
data,
});

if (
data.pending === false &&
currentDistribution.pending === true
) {
if (data.pending === false && currentDistribution.pending === true) {
await this.createShipmentsForDistribution(distributionId);
await this.populateUnfulfilledWishlists(distributionId);
}

return updatedDistribution;
}

private static async populateUnfulfilledWishlists(
distributionId: number
): Promise<void> {
try {
const distribution = await db.distribution.findUnique({
where: { id: distributionId },
include: {
allocations: {
include: {
lineItem: {
include: {
generalItem: true,
},
},
},
},
},
});

if (!distribution || !distribution.partnerId) return;

const partnerId = distribution.partnerId;

const allocatedByGeneralItemId = new Map<number, number>();
for (const allocation of distribution.allocations) {
const generalItem = allocation.lineItem.generalItem;
if (!generalItem) continue;
const current = allocatedByGeneralItemId.get(generalItem.id) ?? 0;
allocatedByGeneralItemId.set(
generalItem.id,
current + allocation.lineItem.quantity
);
}

if (allocatedByGeneralItemId.size === 0) return;

// Fetch all partner requests for the general items in this distribution
const generalItemIds = Array.from(allocatedByGeneralItemId.keys());
const requests = await db.generalItemRequest.findMany({
where: {
partnerId,
generalItemId: { in: generalItemIds },
},
});

if (requests.length === 0) return;

const unfulfilledEntries: Array<{
requestId: number;
finalQuantity: number;
}> = [];

for (const request of requests) {
const allocated =
allocatedByGeneralItemId.get(request.generalItemId) ?? 0;
if (allocated < request.quantity) {
await db.generalItemRequest.update({
where: { id: request.id },
data: { finalQuantity: allocated },
});
unfulfilledEntries.push({
requestId: request.id,
finalQuantity: allocated,
});
Comment thread
ND68 marked this conversation as resolved.
Outdated
}
}

if (unfulfilledEntries.length > 0) {
await WishlistService.createUnfulfilledWishlistEntries(
unfulfilledEntries
);
}
} catch (error) {
console.error(
"Failed to populate unfulfilled wishlists for distribution:",
distributionId,
error
);
}
}

private static async createShipmentsForDistribution(distributionId: number) {
const allocations = await db.allocation.findMany({
where: { distributionId },
Expand Down Expand Up @@ -766,4 +843,4 @@ export default class DistributionService {
where: { id: distributionId },
});
}
}
}
24 changes: 20 additions & 4 deletions src/services/generalItemRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@/types/api/donorOffer.types";
import { Filters } from "@/types/api/filter.types";
import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table";
import { WishlistService } from "./wishlistService";

export class GeneralItemRequestService {
static async getById(id: number) {
Expand Down Expand Up @@ -250,6 +251,21 @@ export class GeneralItemRequestService {
)
);

// After persisting final quantities, auto-populate wishlists for any
// requests that were not fully fulfilled (finalQuantity < quantity).
const unfulfilledUpdates = updates.filter(({ requestId, finalQuantity }) => {
const request = requests.find(r => r.id === requestId);
return request && finalQuantity < request.quantity;
});

if (unfulfilledUpdates.length > 0) {
try {
await WishlistService.createUnfulfilledWishlistEntries(unfulfilledUpdates);
} catch {
// silent catch
}
}
Comment thread
ND68 marked this conversation as resolved.
Outdated

return results;
}

Expand Down Expand Up @@ -311,10 +327,10 @@ export class GeneralItemRequestService {
);
}

const duplicateRequest = await tx.generalItemRequest.findFirst({
const duplicateRequest = await tx.generalItemRequest.findUnique({
where: {
generalItemId: targetGeneralItemId,
partnerId: existingRequest.partnerId,
generalItemId_partnerId: {generalItemId: targetGeneralItemId,
partnerId: existingRequest.partnerId,}
},
});

Expand Down Expand Up @@ -537,4 +553,4 @@ export class GeneralItemRequestService {
total,
};
}
}
}
68 changes: 67 additions & 1 deletion src/services/wishlistService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { MatchingService } from "./matchingService";

const AUTO_GENERATED_COMMENT = "Automatically generated due to unfulfilled request";

export class WishlistService {
static async createWishlist(data: CreateWishlistData) {
const partner = await db.user.findUnique({
Expand Down Expand Up @@ -42,6 +44,70 @@ export class WishlistService {
return wishlist;
}

/**
* For each request where finalQuantity < quantity, automatically creates or
* updates a wishlist entry for the partner with the unfulfilled quantity.
* If an entry with the same name already exists for the partner, the
* unfulfilled quantity is added to it.
*/
Comment on lines +47 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

JSDoc comment contradicts actual implementation

The doc says "the unfulfilled quantity is added to it", but line 83 does data: { quantity: unfulfilledQuantity } which replaces the existing quantity rather than incrementing it. The PR description also describes the additive behavior. The comment should be corrected to match the code's actual semantics:

 * If an entry with the same name already exists for the partner, its
 * quantity is replaced with the new unfulfilled quantity.

static async createUnfulfilledWishlistEntries(requests: { requestId: number; finalQuantity: number }[]) {
const fullRequests = await db.generalItemRequest.findMany({
where: { id: { in: requests.map(r => r.requestId) } },
include: { partner: true, generalItem: true }
});

// PRE-FETCH: Get all existing auto-generated wishlist items for these partners in ONE query
const partnerIds = [...new Set(fullRequests.map(r => r.partnerId))];
const existingWishlistItems = await db.wishlist.findMany({
where: {
partnerId: { in: partnerIds },
generalItemId: null, // Only targeting auto-generated ones
}
});
Comment thread
ND68 marked this conversation as resolved.
Outdated

for (const update of requests) {
const request = fullRequests.find((r) => r.id === update.requestId);
if (!request || !request.partner.enabled || request.partner.pending) continue;

const unfulfilledQuantity = request.quantity - update.finalQuantity;
if (unfulfilledQuantity <= 0) continue;

const itemName = request.generalItem.title;
const existing = existingWishlistItems.find(
(w) => w.partnerId === request.partnerId && w.name === itemName
);

if (existing) {
await db.wishlist.update({
where: { id: existing.id },
data: { quantity: unfulfilledQuantity },
});
} else {
const wishlist = await db.wishlist.create({
data: {
name: itemName,
quantity: unfulfilledQuantity,
priority: request.priority ?? "LOW",
comments: AUTO_GENERATED_COMMENT,
partnerId: request.partnerId,
},
});

try {
await MatchingService.add({
wishlistId: wishlist.id,
title: wishlist.name,
});
} catch (error) {
console.warn(
"Failed to generate embedding for auto-created wishlist entry:",
error
);
}
}
Comment thread
ND68 marked this conversation as resolved.
Outdated
}
}

/**
* Automatically updates lastUpdated
*/
Expand Down Expand Up @@ -247,4 +313,4 @@ export class WishlistService {
}
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing newline at end of file

Both wishlistService.ts and generalItemRequestService.ts now end without a trailing newline (the diff shows \ No newline at end of file). Most linters (e.g., ESLint's eol-last rule) treat this as a warning or error. Please restore the trailing newline to keep the linter happy and avoid noisy future diffs.

The same issue exists at the end of src/services/generalItemRequestService.ts.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Loading