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
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

import { auth } from "@/auth";
import DonorOfferService from "@/services/donorOfferService";
import { GeneralItemRequestService } from "@/services/generalItemRequestService";
import UserService from "@/services/userService";
import {
Expand Down Expand Up @@ -95,10 +94,11 @@ export async function PATCH(
throw new ArgumentError(updateParsed.error.message);
}

await DonorOfferService.updateRequestFinalQuantity(
await GeneralItemRequestService.updateWishlist(
parsed.data.requestId,
updateParsed.data.finalQuantity
);
console.log(updateParsed.data.finalQuantity)

return NextResponse.json(ok());
}
Expand Down
10 changes: 2 additions & 8 deletions src/services/distributionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,9 +367,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,10 +669,7 @@ export default class DistributionService {
data,
});

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

Expand Down Expand Up @@ -766,4 +760,4 @@ export default class DistributionService {
where: { id: distributionId },
});
}
}
}
76 changes: 64 additions & 12 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 @@ -198,6 +199,36 @@ export class GeneralItemRequestService {
}
}

static async updateWishlist(requestId: number, newFinalQuantity: number) {
const existingRequest = await db.generalItemRequest.findUnique({
where: { id: requestId },
select: { finalQuantity: true, id: true }
});

if (!existingRequest) throw new NotFoundError("Request not found");

const oldFinalQuantity = existingRequest.finalQuantity ?? 0;
const unfulfilledDelta = oldFinalQuantity - newFinalQuantity;

return await db.$transaction(async (tx) => {
const updated = await tx.generalItemRequest.update({
where: { id: requestId },
data: { finalQuantity: newFinalQuantity }
});

if (unfulfilledDelta !== 0) {
await WishlistService.createUnfulfilledWishlistEntries([
{
requestId: requestId,
unfulfilledQuantity: unfulfilledDelta,
},
]);
}

return updated;
});
}

static async bulkUpdateRequests(
updates: Array<{ requestId: number; finalQuantity: number }>
) {
Expand Down Expand Up @@ -241,14 +272,35 @@ export class GeneralItemRequestService {
throw new ArgumentError("Cannot update requests after the partner response deadline has passed.");
}

const results = await db.$transaction(
updates.map(({ requestId, finalQuantity }) =>
db.generalItemRequest.update({
where: { id: requestId },
data: { finalQuantity },
})
)
);
const wishlistDeltas = updates.map(u => {
const existingRequest = requests.find(r => r.id === u.requestId);
if (!existingRequest) return null;

const delta = (existingRequest.finalQuantity ?? 0) - u.finalQuantity;

return delta !== 0
? { requestId: u.requestId, unfulfilledQuantity: delta }
: null;
}).filter((update): update is { requestId: number; unfulfilledQuantity: number } => update !== null);

const results = await db.$transaction(async (tx) => {
// Execute the request updates
const updateResults = await Promise.all(
updates.map(({ requestId, finalQuantity }) =>
tx.generalItemRequest.update({
where: { id: requestId },
data: { finalQuantity },
})
)
);

// 3. Sync the wishlists using the deltas
if (wishlistDeltas.length > 0) {
await WishlistService.createUnfulfilledWishlistEntries(wishlistDeltas);
}

return updateResults;
});

return results;
}
Expand Down Expand Up @@ -311,10 +363,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 +589,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(updates: { requestId: number; unfulfilledQuantity: number }[]) {
const fullRequests = await db.generalItemRequest.findMany({
where: { id: { in: updates.map(u => u.requestId) } },
include: { partner: true, generalItem: true }
});

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

// Use the delta (unfulfilledQuantity) passed from the service
const delta = update.unfulfilledQuantity;

// IMPORTANT: Search by generalItemId to avoid duplicates
const existing = await db.wishlist.findFirst({
where: {
partnerId: request.partnerId,
generalItemId: request.generalItemId,
}
});

if (existing) {
const newQty = (existing.quantity ?? 0) + delta;
if (newQty <= 0) {
await db.wishlist.delete({ where: { id: existing.id } });
} else {
await db.wishlist.update({
where: { id: existing.id },
data: { quantity: newQty },
});
}
} else if (delta > 0) {
const wishlist = await db.wishlist.create({
data: {
name: request.generalItem.title,
quantity: delta,
priority: request.priority ?? "LOW",
comments: AUTO_GENERATED_COMMENT,
partnerId: request.partnerId,
generalItemId: request.generalItemId, // KEY: Link it!
},
});

try {
await MatchingService.add({
wishlistId: wishlist.id,
title: wishlist.name,
});
} catch (error) {
console.warn(
"Failed to generate embedding for auto-created wishlist entry:",
error
);
}
}
}
}

/**
* 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