From 1bc189914920468ab056aee9c816c43f0e7dc554 Mon Sep 17 00:00:00 2001 From: Adrian Andersen Date: Tue, 11 Jun 2024 12:59:49 +0200 Subject: [PATCH] Update Match Algorithm (#512) * feat(MatchFinder): do not allow matches with oneself * feat(MatcherSpec): add option to include sender items from other branches * feat(MatcherSpec): add option for specifying items all receivers should receive * feat(UserMatch): deadline overrides * fix(UserMatch): use blId instead of customerItem ID for user matches * feat(match-transfer): update info text * feat(MatchFinder): use aggregation to find receivers * feat(MatchFinder): make additionalReceiverItems more branch dependent --- package.json | 2 +- .../match-finder-2/match-finder.spec.ts | 57 ++++-- .../helpers/match-finder-2/match-finder.ts | 11 +- .../match-finder-2/match-testing-utils.ts | 10 + .../helpers/match-finder-2/match-utils.ts | 7 +- src/collections/match/match.schema.ts | 19 +- .../match-generate-operation-helper.ts | 187 ++++++++++-------- .../operations/match-generate.operation.ts | 15 +- .../match-getall-me-operation-helper.ts | 41 ++-- .../match/operations/match-operation-utils.ts | 8 +- .../match-transfer-item.operation.ts | 18 +- .../order-item-validator.spec.ts | 8 +- .../order-item-validator.ts | 6 +- src/storage/blDocumentStorage.ts | 2 +- src/storage/blStorageHandler.ts | 2 +- yarn.lock | 8 +- 16 files changed, 235 insertions(+), 166 deletions(-) diff --git a/package.json b/package.json index 6c8d5650..c1a064a2 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "license": "ISC", "dependencies": { "@boklisten/bl-email": "^1.8.1", - "@boklisten/bl-model": "^0.25.37", + "@boklisten/bl-model": "^0.25.41", "@boklisten/bl-post-office": "^0.5.56", "@napi-rs/image": "^1.8.0", "@sendgrid/mail": "^7.7.0", diff --git a/src/collections/match/helpers/match-finder-2/match-finder.spec.ts b/src/collections/match/helpers/match-finder-2/match-finder.spec.ts index 3832b59a..fd863d5d 100644 --- a/src/collections/match/helpers/match-finder-2/match-finder.spec.ts +++ b/src/collections/match/helpers/match-finder-2/match-finder.spec.ts @@ -22,6 +22,7 @@ import { groupMatchesByUser, seededRandom, shuffler, + createMatchableUsersWithIdPrefix, } from "./match-testing-utils"; chai.use(chaiAsPromised); @@ -32,6 +33,7 @@ const andrine = createFakeMatchableUser("andrine", "book1", "book2", "book3"); const beate = createFakeMatchableUser("beate", "book1", "book2", "book3"); const monika = createFakeMatchableUser("monika", "book4"); +const mons = createFakeMatchableUser("mons", "book4"); const mathias = createFakeMatchableUser( "mathias", @@ -40,6 +42,13 @@ const mathias = createFakeMatchableUser( "book3", "book4", ); +const mathea = createFakeMatchableUser( + "mathea", + "book1", + "book2", + "book3", + "book4", +); describe("Full User Match", () => { it("should be able to full match with other user", () => { @@ -87,7 +96,7 @@ describe("Full User Match", () => { it("should be able to create multiple full matches with overlapping books", () => { const matchFinder = new MatchFinder( [monika, mathias], - [mathias, monika, mathias], + [mathea, mons, mathea], ); matchFinder.generateMatches(); expect( @@ -101,6 +110,18 @@ describe("Full User Match", () => { ).length, ).to.equal(0); }); + + it("should not be able to match users with themselves", () => { + const matchFinder = new MatchFinder([andrine], [andrine]); + const matches = matchFinder.generateMatches(); + + const andrineXstand = createFakeStandMatch( + andrine, + andrine.items, + andrine.items, + ); + assert.deepEqual(matches, [andrineXstand]); + }); }); describe("Partly User Match", () => { @@ -290,14 +311,15 @@ describe("Large User Groups", () => { it("can sufficiently match realistic user data with itself", () => { const shuffle = shuffler(seededRandom(12345)); const rawData = ullern_test_users; - const test_users: MatchableUser[] = rawData.map(({ id, items }) => ({ - id, - items: new Set(items.map((item) => item["$numberLong"])), - })); + const test_senders = createMatchableUsersWithIdPrefix(rawData, "_sender"); + const test_receivers = createMatchableUsersWithIdPrefix( + rawData, + "_receiver", + ); const matchFinder = new MatchFinder( - shuffle(test_users.slice()), - shuffle(test_users.slice()), + shuffle(test_senders.slice()), + shuffle(test_receivers.slice()), ); const matches = Array.from(matchFinder.generateMatches()); @@ -305,24 +327,25 @@ describe("Large User Groups", () => { const numberOfMatchesPerType = calculateNumberOfMatchesPerType(matches); expect(numberOfMatchesPerType.userMatches).to.be.lessThan( - test_users.length * 1.4, + test_senders.length * 1.4, ); expect(numberOfMatchesPerType.standMatches).to.be.lessThanOrEqual( - test_users.length * 0.1, + test_senders.length * 0.1, ); }); it("can sufficiently match realistic user data with a modified version of itself", () => { const shuffle = shuffler(seededRandom(123454332)); const rawData = ullern_test_users; - const test_users: MatchableUser[] = rawData.map(({ id, items }) => ({ - id, - items: new Set(items.map((item) => item["$numberLong"])), - })); + const test_senders = createMatchableUsersWithIdPrefix(rawData, "_sender"); + const test_receivers = createMatchableUsersWithIdPrefix( + rawData, + "_receiver", + ); const matchFinder = new MatchFinder( - shuffle(test_users.slice()).slice(33), - shuffle(test_users.slice()).slice(20), + shuffle(test_senders.slice()).slice(33), + shuffle(test_receivers.slice()).slice(20), ); const matches = Array.from(matchFinder.generateMatches()); @@ -330,10 +353,10 @@ describe("Large User Groups", () => { const numberOfMatchesPerType = calculateNumberOfMatchesPerType(matches); expect(numberOfMatchesPerType.userMatches).to.be.lessThan( - test_users.flat().length * 1.4, + test_senders.flat().length * 1.4, ); expect(numberOfMatchesPerType.standMatches).to.be.lessThanOrEqual( - test_users.flat().length * 0.2, + test_senders.flat().length * 0.2, ); }); diff --git a/src/collections/match/helpers/match-finder-2/match-finder.ts b/src/collections/match/helpers/match-finder-2/match-finder.ts index f6970c51..b9658819 100644 --- a/src/collections/match/helpers/match-finder-2/match-finder.ts +++ b/src/collections/match/helpers/match-finder-2/match-finder.ts @@ -123,7 +123,7 @@ export class MatchFinder { ); if (sender) { if (hasDifference(intersect(sentItems, sender.items), sentItems)) { - throw "Sender cannot give away more books than they have!"; + throw new Error("Sender cannot give away more books than they have!"); } sender.items = difference(sender.items, sentItems); originalSenders = removeFullyMatchedUsers(originalSenders); @@ -146,11 +146,18 @@ export class MatchFinder { if ( hasDifference(intersect(receivedItems, receiver.items), receivedItems) ) { - throw "Receiver cannot receive more books than they want!"; + throw new Error("Receiver cannot receive more books than they want!"); } receiver.items = difference(receiver.items, receivedItems); originalReceivers = removeFullyMatchedUsers(originalReceivers); } + + if ( + senderId === receiverId && + match.variant === CandidateMatchVariant.UserMatch + ) { + throw new Error("Receiver and sender cannot be the same person"); + } } if (originalSenders.length > 0 || originalReceivers.length > 0) { diff --git a/src/collections/match/helpers/match-finder-2/match-testing-utils.ts b/src/collections/match/helpers/match-finder-2/match-testing-utils.ts index eca77c95..9b39e8b5 100644 --- a/src/collections/match/helpers/match-finder-2/match-testing-utils.ts +++ b/src/collections/match/helpers/match-finder-2/match-testing-utils.ts @@ -115,6 +115,16 @@ export const shuffler = return list; }; +export function createMatchableUsersWithIdPrefix( + rawData: { id: string; items: { $numberLong: string }[] }[], + idPrefix: string, +): MatchableUser[] { + return rawData.map(({ id, items }) => ({ + id: id + idPrefix, + items: new Set(items.map((item) => item["$numberLong"])), + })); +} + /** * Utility method to print some stats about the matching * so that one can evaluate the performance of the matcher diff --git a/src/collections/match/helpers/match-finder-2/match-utils.ts b/src/collections/match/helpers/match-finder-2/match-utils.ts index 318e9341..f4c29915 100644 --- a/src/collections/match/helpers/match-finder-2/match-utils.ts +++ b/src/collections/match/helpers/match-finder-2/match-utils.ts @@ -114,7 +114,8 @@ export function tryFindOneWayMatch( receivers: MatchableUser[], ): MatchableUser | null { return receivers.reduce((best: MatchableUser | null, next) => { - return !hasDifference(sender.items, next.items) && + return sender.id !== next.id && + !hasDifference(sender.items, next.items) && (best === null || next.items.size < best.items.size) ? next : best; @@ -135,6 +136,7 @@ export function tryFindTwoWayMatch( return ( receivers.find( (receiver) => + sender.id !== receiver.id && !hasDifference(sender.items, receiver.items) && !hasDifference(receiver.items, sender.items), ) ?? null @@ -153,6 +155,9 @@ export function tryFindPartialMatch( ): MatchableUser | null { let bestReceiver: MatchableUser | null = null; for (const receiver of receivers) { + if (sender.id === receiver.id) { + continue; + } const matchableItems = intersect(sender.items, receiver.items); const bestMatchableItems = intersect( sender.items, diff --git a/src/collections/match/match.schema.ts b/src/collections/match/match.schema.ts index 91f10b05..ffd1cf21 100644 --- a/src/collections/match/match.schema.ts +++ b/src/collections/match/match.schema.ts @@ -61,24 +61,19 @@ const userMatchSchema = { ref: BlCollectionName.Items, default: undefined, }, - // customerItems owned by sender which have been given to anyone. May differ from receivedCustomerItems + // unique items owned by sender which have been given to anyone. May differ from receivedBlIds // when a book is borrowed and handed over to someone other than the technical owner's match - deliveredCustomerItems: { - type: [ObjectId], - ref: BlCollectionName.CustomerItems, - default: undefined, - }, - // items which have been received by the receiver from anyone - receivedCustomerItems: { - type: [ObjectId], - ref: BlCollectionName.CustomerItems, - default: undefined, - }, + deliveredBlIds: [String], + // unique items which have been received by the receiver from anyone + receivedBlIds: [String], // if true, disallow handing the items out or in at a stand, only allow match exchange itemsLockedToMatch: { type: Boolean, default: undefined, }, + // if receiver items have overrides, the generated customer items will + // get the deadline specified in the override instead of using the branch defined deadline + deadlineOverrides: [{ item: String, deadline: Date }], }; /** @see StandMatch */ diff --git a/src/collections/match/operations/match-generate-operation-helper.ts b/src/collections/match/operations/match-generate-operation-helper.ts index 38ba72df..85bb428d 100644 --- a/src/collections/match/operations/match-generate-operation-helper.ts +++ b/src/collections/match/operations/match-generate-operation-helper.ts @@ -7,6 +7,7 @@ import { } from "@boklisten/bl-model"; import { ObjectId } from "mongodb"; +import { isBoolean } from "../../../helper/typescript-helpers"; import { BlDocumentStorage } from "../../../storage/blDocumentStorage"; import { CandidateMatchVariant, @@ -25,9 +26,15 @@ export interface MatcherSpec { startTime: string; deadlineBefore: string; matchMeetingDurationInMS: number; + includeSenderItemsFromOtherBranches: boolean; + additionalReceiverItems: { branch: string; items: string[] }[]; + deadlineOverrides: { item: string; deadline: string }[]; } -export function candidateMatchToMatch(candidate: MatchWithMeetingInfo): Match { +export function candidateMatchToMatch( + candidate: MatchWithMeetingInfo, + deadlineOverrides: { item: string; deadline: string }[], +): Match { switch (candidate.variant) { case CandidateMatchVariant.StandMatch: return new StandMatch( @@ -42,6 +49,7 @@ export function candidateMatchToMatch(candidate: MatchWithMeetingInfo): Match { candidate.receiverId, Array.from(candidate.items), candidate.meetingInfo, + deadlineOverrides, ); } } @@ -51,17 +59,26 @@ export function candidateMatchToMatch(candidate: MatchWithMeetingInfo): Match { * * @param branchIds The IDs of branches to search for users and items * @param deadlineBefore Select customer items that have a deadlineBefore between now() and this deadlineBefore + * @param includeSenderItemsFromOtherBranches whether the remainder of the items that a customer has in possession should be added to the match, even though they were not handed out at the specified branchIds * @param customerItemStorage */ export async function getMatchableSenders( branchIds: string[], deadlineBefore: string, + includeSenderItemsFromOtherBranches: boolean, customerItemStorage: BlDocumentStorage, ): Promise { - const branchCustomerItems = await customerItemStorage.aggregate([ + const groupByCustomerStep = { + $group: { + _id: "$customer", + id: { $first: "$customer" }, + items: { $addToSet: "$item" }, + }, + }; + + let aggregatedSenders = (await customerItemStorage.aggregate([ { $match: { - // TODO: Check that the book is going to be returned this match session/semester returned: false, buyout: false, cancel: false, @@ -73,13 +90,29 @@ export async function getMatchableSenders( deadline: { $gt: new Date(), $lte: new Date(deadlineBefore) }, }, }, - ]); + groupByCustomerStep, + ])) as { id: string; items: string[] }[]; - return groupItemsByUser( - branchCustomerItems, - (customerItem) => customerItem.customer.toString(), - (customerItem) => [customerItem.item.toString()], - ); + if (includeSenderItemsFromOtherBranches) { + aggregatedSenders = (await customerItemStorage.aggregate([ + { + $match: { + customer: { $in: aggregatedSenders.map((sender) => sender.id) }, + returned: false, + buyout: false, + cancel: false, + buyback: false, + deadline: { $gt: new Date(), $lte: new Date(deadlineBefore) }, + }, + }, + groupByCustomerStep, + ])) as { id: string; items: string[] }[]; + } + + return aggregatedSenders.map((sender) => ({ + id: sender.id, + items: new Set(sender.items), + })); } /** @@ -87,12 +120,14 @@ export async function getMatchableSenders( * * @param branchIds The IDs of branches to search for users and items * @param orderStorage + * @param additionalReceiverItems items that all receivers in the predefined branches want */ export async function getMatchableReceivers( branchIds: string[], orderStorage: BlDocumentStorage, + additionalReceiverItems: { branch: string; items: string[] }[], ): Promise { - const branchOrders = await orderStorage.aggregate([ + const aggregatedReceivers = (await orderStorage.aggregate([ { $match: { placed: true, @@ -129,12 +164,31 @@ export async function getMatchableReceivers( }, }, }, - ]); - return groupItemsByUser( - branchOrders, - (order) => order.customer.toString(), - (order) => order.orderItems.map((oi) => oi.item.toString()), - ); + { + $unwind: "$orderItems", + }, + { + $group: { + _id: "$customer", + id: { $first: "$customer" }, + items: { $addToSet: "$orderItems.item" }, + branches: { $addToSet: "$branch" }, + }, + }, + ])) as { id: string; items: string[]; branches: string[] }[]; + + for (const branchReceiverItems of additionalReceiverItems) { + for (const receiver of aggregatedReceivers) { + if (receiver.branches.includes(branchReceiverItems.branch)) { + receiver.items = [...receiver.items, ...branchReceiverItems.items]; + } + } + } + + return aggregatedReceivers.map((receiver) => ({ + id: receiver.id, + items: new Set(receiver.items), + })); } export function verifyMatcherSpec( @@ -143,26 +197,14 @@ export function verifyMatcherSpec( const m = matcherSpec as Record; return ( m && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Array.isArray(m.branches) && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Array.isArray(m.userMatchLocations) && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - m.branches.every( + Array.isArray(m["branches"]) && + Array.isArray(m["userMatchLocations"]) && + m["branches"].every( (branchId) => typeof branchId === "string" && branchId.length === 24, ) && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - typeof m.standLocation === "string" && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - m.standLocation.length > 0 && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - m.userMatchLocations.every( + typeof m["standLocation"] === "string" && + m["standLocation"].length > 0 && + m["userMatchLocations"].every( (location) => typeof location.name === "string" && location.name.length > 0 && @@ -170,56 +212,31 @@ export function verifyMatcherSpec( (Number.isInteger(location.simultaneousMatchLimit) && location.simultaneousMatchLimit > 0)), ) && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - typeof m.startTime === "string" && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - !isNaN(new Date(m.startTime).getTime()) && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - typeof m.deadlineBefore === "string" && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - !isNaN(new Date(m.deadlineBefore).getTime()) && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - new Date(m.deadlineBefore).getTime() > new Date().getTime() && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - typeof m.matchMeetingDurationInMS === "number" && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - !isNaN(m.matchMeetingDurationInMS) + typeof m["startTime"] === "string" && + !isNaN(new Date(m["startTime"]).getTime()) && + typeof m["deadlineBefore"] === "string" && + !isNaN(new Date(m["deadlineBefore"]).getTime()) && + new Date(m["deadlineBefore"]).getTime() > new Date().getTime() && + typeof m["matchMeetingDurationInMS"] === "number" && + !isNaN(m["matchMeetingDurationInMS"]) && + isBoolean(m["includeSenderItemsFromOtherBranches"]) && + Array.isArray(m["additionalReceiverItems"]) && + m["additionalReceiverItems"].every( + (entry) => + typeof entry["branch"] === "string" && + entry["branch"].length === 24 && + Array.isArray(entry["items"]) && + entry["items"].every( + (itemId) => typeof itemId === "string" && itemId.length === 24, + ), + ) && + Array.isArray(m["deadlineOverrides"]) && + m["deadlineOverrides"].every( + (override) => + typeof override["item"] === "string" && + override["item"].length === 24 && + typeof override["deadline"] === "string" && + !isNaN(new Date(override["deadline"]).getTime()), + ) ); } - -/** - * Reduce a set of documents to a list of users and their associated items. - * - * Which user is associated with which document and what their items are is - * defined by the provided selectors. If items are added to a user from several - * documents, all the unique ones are included in the result. - * - * @param fromDocuments The list of documents to gather users and items from - * @param selectUserId A function which given a document returns the user - * associated with that document - * @param selectItems A function which given a document returns the items - * the user is associated with through that document - */ -function groupItemsByUser( - fromDocuments: T[], - selectUserId: (document: T) => string, - selectItems: (document: T) => string[], -): MatchableUser[] { - const itemsByUserId: Map = new Map(); - for (const document of fromDocuments) { - const items = itemsByUserId.get(selectUserId(document)) ?? []; - itemsByUserId.set(selectUserId(document), items); - items.push(...selectItems(document)); - } - return Array.from(itemsByUserId.entries()).map(([id, items]) => ({ - id, - items: new Set(items), - })); -} diff --git a/src/collections/match/operations/match-generate.operation.ts b/src/collections/match/operations/match-generate.operation.ts index e0c4bffb..6ef9ddd4 100644 --- a/src/collections/match/operations/match-generate.operation.ts +++ b/src/collections/match/operations/match-generate.operation.ts @@ -51,13 +51,18 @@ export class MatchGenerateOperation implements Operation { getMatchableSenders( matcherSpec.branches, matcherSpec.deadlineBefore, + matcherSpec.includeSenderItemsFromOtherBranches, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this.customerItemStorage, ), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - getMatchableReceivers(matcherSpec.branches, this.orderStorage), + getMatchableReceivers( + matcherSpec.branches, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.orderStorage, + matcherSpec.additionalReceiverItems, + ), ]); if (senders.length === 0 && receivers.length === 0) { throw new BlError("No senders or receivers"); @@ -68,7 +73,9 @@ export class MatchGenerateOperation implements Operation { matcherSpec.userMatchLocations, new Date(matcherSpec.startTime), matcherSpec.matchMeetingDurationInMS, - ).map((candidate) => candidateMatchToMatch(candidate)); + ).map((candidate) => + candidateMatchToMatch(candidate, matcherSpec.deadlineOverrides), + ); if (matches.length === 0) { throw new BlError("No matches generated"); } diff --git a/src/collections/match/operations/match-getall-me-operation-helper.ts b/src/collections/match/operations/match-getall-me-operation-helper.ts index 3cee88ad..129dccc6 100644 --- a/src/collections/match/operations/match-getall-me-operation-helper.ts +++ b/src/collections/match/operations/match-getall-me-operation-helper.ts @@ -13,6 +13,7 @@ import { } from "@boklisten/bl-model"; import { BlDocumentStorage } from "../../../storage/blDocumentStorage"; +import { CustomerItemActiveBlid } from "../../customer-item/helpers/customer-item-active-blid"; function selectMatchRelevantUserDetails({ name, @@ -24,12 +25,12 @@ function selectMatchRelevantUserDetails({ }; } -function mapCustomerItemIdsToItemIds( - customerItemIds: string[], +function mapBlIdsToItemIds( + blIds: string[], customerItemsMap: Map, ): { [customerItemId: string]: string } { return Object.fromEntries( - customerItemIds.map(String).map((customerItemId) => { + blIds.map(String).map((customerItemId) => { const customerItem = customerItemsMap.get(customerItemId); if (customerItem === undefined) { throw new BlError(`No customerItem with id ${customerItemId} found`); @@ -91,8 +92,8 @@ function addDetailsToMatch( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore receiverDetails: selectMatchRelevantUserDetails(receiverDetails), - customerItemToItemMap: mapCustomerItemIdsToItemIds( - [...match.receivedCustomerItems, ...match.deliveredCustomerItems], + blIdToItemMap: mapBlIdsToItemIds( + [...match.receivedBlIds, ...match.deliveredBlIds], customerItemsMap, ), itemDetails: mapItemIdsToItemDetails(match.expectedItems, itemsMap), @@ -124,16 +125,12 @@ export async function addDetailsToAllMatches( ), ); - const customerItemsToMap = Array.from( + const blIdsToMap = Array.from( matches.reduce( - (customerItems, match) => + (blIds, match) => match._variant === MatchVariant.UserMatch - ? new Set([ - ...customerItems, - ...match.receivedCustomerItems.map(String), - ...match.deliveredCustomerItems.map(String), - ]) - : customerItems, + ? new Set([...blIds, ...match.receivedBlIds, ...match.deliveredBlIds]) + : blIds, new Set(), ), ); @@ -150,17 +147,21 @@ export async function addDetailsToAllMatches( new Set(), ), ); - const customerItemsMap = new Map( + const blIdsToCustomerItemMap = new Map( await Promise.all( - customerItemsToMap.map((id) => - customerItemStorage - .get(id) - .then((customerItem): [string, CustomerItem] => [id, customerItem]), + blIdsToMap.map((blId) => + new CustomerItemActiveBlid(customerItemStorage) + .getActiveCustomerItems(blId) + .then((customerItems): [string, CustomerItem] => [ + blId, + // There should never be more than one active customerItem related to a blId + customerItems[0]!, + ]), ), ), ); const itemsToMapFromCustomerItems = Array.from( - Array.from(customerItemsMap.values()).reduce( + Array.from(blIdsToCustomerItemMap.values()).reduce( (itemIds, customerItem) => new Set([...itemIds, String(customerItem.item)]), new Set(), @@ -177,6 +178,6 @@ export async function addDetailsToAllMatches( ); return matches.map((match) => - addDetailsToMatch(match, userDetailsMap, customerItemsMap, itemsMap), + addDetailsToMatch(match, userDetailsMap, blIdsToCustomerItemMap, itemsMap), ); } diff --git a/src/collections/match/operations/match-operation-utils.ts b/src/collections/match/operations/match-operation-utils.ts index ddc5b4e6..fc6a1c0b 100644 --- a/src/collections/match/operations/match-operation-utils.ts +++ b/src/collections/match/operations/match-operation-utils.ts @@ -19,6 +19,7 @@ export async function createMatchOrder( customerItem: CustomerItem, userDetailId: string, isSender: boolean, + deadlineOverrides?: { item: string; deadline: string }[], ): Promise { const itemStorage = new BlDocumentStorage( BlCollectionName.Items, @@ -76,6 +77,9 @@ export async function createMatchOrder( movedFromOrder = originalReceiverOrder.id; } + const deadlineOverride = deadlineOverrides?.find( + (override) => override.item === item.id, + ); return { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -103,7 +107,9 @@ export async function createMatchOrder( taxAmount: 0, info: { from: new Date(), - to: newRentPeriod.date, + to: deadlineOverride + ? new Date(deadlineOverride.deadline) + : newRentPeriod.date, numberOfPeriods: 1, periodType: "semester", }, diff --git a/src/collections/match/operations/match-transfer-item.operation.ts b/src/collections/match/operations/match-transfer-item.operation.ts index 94bd616d..23a58a6b 100644 --- a/src/collections/match/operations/match-transfer-item.operation.ts +++ b/src/collections/match/operations/match-transfer-item.operation.ts @@ -101,7 +101,7 @@ export class MatchTransferItemOperation implements Operation { throw new BlError("Item not in receiver expectedItems").code(805); } - if (receiverUserMatch.receivedCustomerItems.includes(customerItem.id)) { + if (receiverUserMatch.receivedBlIds.includes(customerItem.blid!)) { throw new BlError("Receiver has already received this item").code(806); } const senderMatches = await getAllMatchesForUser( @@ -113,14 +113,14 @@ export class MatchTransferItemOperation implements Operation { const senderUserMatch = senderUserMatches.find( (userMatch) => userMatch.expectedItems.includes(customerItem.item as string) && - !userMatch.deliveredCustomerItems.includes(customerItem.item as string), + !userMatch.deliveredBlIds.includes(customerItem.blid!), ); if ( senderUserMatch === undefined || receiverUserMatch.id !== senderUserMatch.id ) { - userFeedback = `Boken du har scannet tilhørte opprinnelig en annen kunde. Boken er nå registrert på deg, men avsender må fortsatt levere sin opprinnelige bok. Ta kontakt med stand for spørsmål.`; + userFeedback = `Boken du har mottatt tilhørte opprinnelig noen andre enn den som ga deg boka. Den har nå blitt registrert på deg, men den som ga deg boka må fortsatt levere sin opprinnelige bok. Ta kontakt med stand for spørsmål.`; } const matchStorage = new BlDocumentStorage( @@ -143,10 +143,7 @@ export class MatchTransferItemOperation implements Operation { await matchStorage.update( receiverUserMatch.id, { - receivedCustomerItems: [ - ...receiverUserMatch.receivedCustomerItems, - customerItem.id, - ], + receivedBlIds: [...receiverUserMatch.receivedBlIds, customerItem.blid!], }, new SystemUser(), ); @@ -155,6 +152,7 @@ export class MatchTransferItemOperation implements Operation { customerItem, receiverUserDetailId, false, + receiverUserMatch.deadlineOverrides, ); const placedReceiverOrder = await orderStorage.add( @@ -170,9 +168,9 @@ export class MatchTransferItemOperation implements Operation { await matchStorage.update( senderUserMatch.id, { - deliveredCustomerItems: [ - ...senderUserMatch.deliveredCustomerItems, - customerItem.id, + deliveredBlIds: [ + ...senderUserMatch.deliveredBlIds, + customerItem.blid!, ], }, new SystemUser(), diff --git a/src/collections/order/helpers/order-validator/order-item-validator/order-item-validator.spec.ts b/src/collections/order/helpers/order-validator/order-item-validator/order-item-validator.spec.ts index 78fb56bb..dcd05488 100644 --- a/src/collections/order/helpers/order-validator/order-item-validator/order-item-validator.spec.ts +++ b/src/collections/order/helpers/order-validator/order-item-validator/order-item-validator.spec.ts @@ -286,9 +286,9 @@ describe("OrderItemValidator", () => { ); }); - it("should reject if deadline is more than two years into the future and user is not admin", () => { + it("should reject if deadline is more than four years into the future and user is not admin", () => { const deadline = new Date(); - deadline.setFullYear(deadline.getFullYear() + 2); + deadline.setFullYear(deadline.getFullYear() + 4); deadline.setDate(deadline.getDate() + 1); testOrder.orderItems = [ { @@ -333,9 +333,9 @@ describe("OrderItemValidator", () => { .eventually.be.fulfilled; }); - it("should fulfill if deadline is more than two years into the future and user is admin", () => { + it("should fulfill if deadline is more than four years into the future and user is admin", () => { const deadline = new Date(); - deadline.setFullYear(deadline.getFullYear() + 2); + deadline.setFullYear(deadline.getFullYear() + 4); deadline.setDate(deadline.getDate() + 1); testOrder.orderItems = [ { diff --git a/src/collections/order/helpers/order-validator/order-item-validator/order-item-validator.ts b/src/collections/order/helpers/order-validator/order-item-validator/order-item-validator.ts index 08d4c0b6..b8b4492c 100644 --- a/src/collections/order/helpers/order-validator/order-item-validator/order-item-validator.ts +++ b/src/collections/order/helpers/order-validator/order-item-validator/order-item-validator.ts @@ -156,8 +156,8 @@ export class OrderItemValidator { now.getMonth(), now.getDate() - 10, ); - const twoYearsFromNow = new Date( - now.getFullYear() + 2, + const fourYearsFromNow = new Date( + now.getFullYear() + 4, now.getMonth(), now.getDate(), ); @@ -174,7 +174,7 @@ export class OrderItemValidator { return false; } const deadline = new Date(item.info.to); - return deadline > twoYearsFromNow; + return deadline > fourYearsFromNow; }); if (hasExpiredDeadlines) { diff --git a/src/storage/blDocumentStorage.ts b/src/storage/blDocumentStorage.ts index 921150a7..2286cb85 100644 --- a/src/storage/blDocumentStorage.ts +++ b/src/storage/blDocumentStorage.ts @@ -161,7 +161,7 @@ export class BlDocumentStorage }); } - aggregate(aggregation: PipelineStage[]): Promise { + aggregate(aggregation: PipelineStage[]): Promise { return this.mongoDbHandler.aggregate(aggregation); } diff --git a/src/storage/blStorageHandler.ts b/src/storage/blStorageHandler.ts index 0e2a3cb0..3d57e574 100644 --- a/src/storage/blStorageHandler.ts +++ b/src/storage/blStorageHandler.ts @@ -46,7 +46,7 @@ export interface BlStorageHandler { removeMany(ids: string[]): Promise; - aggregate(aggregation: PipelineStage[]): Promise; + aggregate(aggregation: PipelineStage[]): Promise; exists(id: string): Promise; } diff --git a/yarn.lock b/yarn.lock index a656de5c..49e125be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21,10 +21,10 @@ puppeteer "^9.1.1" typescript "^2.6.2" -"@boklisten/bl-model@^0.25.37": - version "0.25.37" - resolved "https://registry.yarnpkg.com/@boklisten/bl-model/-/bl-model-0.25.37.tgz#53cf9ea5b38bc953ada24b3d1c353707d83496fc" - integrity sha512-+KgdcMqD390D9HCQ/3x5uRpSJdSdbj1s/1mjEbmo6oy48IDwnS9qJbzUFXZ4V0G+4QmrG+cE6AJ3MCC8UNiiBA== +"@boklisten/bl-model@^0.25.41": + version "0.25.41" + resolved "https://registry.yarnpkg.com/@boklisten/bl-model/-/bl-model-0.25.41.tgz#81bc3c1d26ac8f91572f2f8c2f53c4a841f036bb" + integrity sha512-u7S5ap2ZjEZo5v+5hou5kmPqmurLP6gTcyNFQMl88yzI1bKOd0dJrfhYil1eDXDm12U7L1FkW1cDQUaezyL9Pg== dependencies: typescript "^5.2.2"