diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 7f897ca1..c7342143 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -161,7 +161,8 @@ public void onEnable() { itemStorageManager, parcelDispatchService, parcelContentManager, - deliveryManager + deliveryManager, + config.settings.allowCollectingFromAnyLocker ); MainGui mainGUI = new MainGui( diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java index 476fc339..1ae9d486 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java @@ -78,6 +78,14 @@ public static class Settings extends OkaeriConfig { @Comment({"", "# Maximum number of parcels that can be stored in a single locker"}) public int maxParcelsPerLocker = 30; + @Comment({ + "", + "# Whether a parcel can be collected from any locker instead of only its destination locker.", + "# false (default): parcels can only be collected from the locker they were sent to.", + "# true: keeps the legacy behavior where every parcel can be collected from every locker." + }) + public boolean allowCollectingFromAnyLocker = false; + @Comment({"", "# Small parcel fee in in-game currency"}) public double smallParcelFee = 10.0; diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index f6852e7a..5ed683ef 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -17,6 +17,7 @@ import com.eternalcode.parcellockers.user.User; import com.eternalcode.parcellockers.user.UserManager; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -33,6 +34,7 @@ public class GuiManager { private final ParcelDispatchService parcelDispatchService; private final ParcelContentManager parcelContentManager; private final DeliveryManager deliveryManager; + private final boolean allowCollectingFromAnyLocker; public GuiManager( ParcelService parcelService, @@ -41,7 +43,8 @@ public GuiManager( ItemStorageManager itemStorageManager, ParcelDispatchService parcelDispatchService, ParcelContentManager parcelContentManager, - DeliveryManager deliveryManager + DeliveryManager deliveryManager, + boolean allowCollectingFromAnyLocker ) { this.parcelService = parcelService; this.lockerManager = lockerManager; @@ -50,6 +53,24 @@ public GuiManager( this.parcelDispatchService = parcelDispatchService; this.parcelContentManager = parcelContentManager; this.deliveryManager = deliveryManager; + this.allowCollectingFromAnyLocker = allowCollectingFromAnyLocker; + } + + /** + * Returns the delivered parcels the receiver may collect at the locker they are interacting with. + * When {@code allowCollectingFromAnyLocker} is enabled the locker restriction is dropped. + * + *

{@code getCollectible} treats a {@code null} destination locker as "collect from any locker", + * so when the restriction is active a concrete {@code currentLocker} is required — passing + * {@code null} here would silently drop the restriction and let the receiver collect from any locker. + */ + public CompletableFuture> getCollectibleParcels(UUID receiver, UUID currentLocker, Page page) { + if (this.allowCollectingFromAnyLocker) { + return this.parcelService.getCollectible(receiver, null, page); + } + Objects.requireNonNull(currentLocker, + "currentLocker must not be null when collection is restricted to the destination locker"); + return this.parcelService.getCollectible(receiver, currentLocker, page); } public void sendParcel(Player sender, Parcel parcel, List items) { diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java index cd101250..b1e10517 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java @@ -8,7 +8,6 @@ import com.eternalcode.parcellockers.gui.GuiView; import com.eternalcode.parcellockers.gui.PaginatedGuiRefresher; import com.eternalcode.parcellockers.parcel.Parcel; -import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.util.PlaceholderUtil; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.util.MaterialUtil; @@ -18,6 +17,7 @@ import dev.triumphteam.gui.guis.PaginatedGui; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import net.kyori.adventure.text.Component; @@ -35,17 +35,20 @@ public class CollectionGui implements GuiView { private final Scheduler scheduler; private final GuiManager guiManager; private final MiniMessage miniMessage; + private final UUID currentLocker; public CollectionGui( GuiSettings guiSettings, Scheduler scheduler, GuiManager guiManager, - MiniMessage miniMessage + MiniMessage miniMessage, + UUID currentLocker ) { this.guiSettings = guiSettings; this.scheduler = scheduler; this.guiManager = guiManager; this.miniMessage = miniMessage; + this.currentLocker = currentLocker; } @Override @@ -66,7 +69,7 @@ public void show(Player player, Page page) { this.setupStaticItems(player, gui); - this.guiManager.getParcelsByReceiver(player.getUniqueId(), page).thenAccept(result -> { + this.guiManager.getCollectibleParcels(player.getUniqueId(), this.currentLocker, page).thenAccept(result -> { if (result == null || result.items().isEmpty()) { gui.setItem(22, this.guiSettings.noParcelsItem.toGuiItem()); this.scheduler.run(() -> gui.open(player)); @@ -78,7 +81,6 @@ public void show(Player player, Page page) { this.setupNavigation(gui, page, result, player, this.guiSettings); result.items().stream() - .filter(parcel -> parcel.status() == ParcelStatus.DELIVERED) .map(parcel -> this.createParcelItemAsync(parcel, parcelItem, player, refresher)) .collect(CompletableFutures.joinList()) .thenAccept(suppliers -> { diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java index 16dae36e..3ee62762 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java @@ -49,7 +49,8 @@ public void show(Player player, UUID entryLocker) { this.guiSettings, this.scheduler, this.guiManager, - this.miniMessage + this.miniMessage, + entryLocker ); gui.setItem(21, this.guiSettings.parcelLockerCollectItem.toGuiItem(event -> collectionGui.show(player))); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java index 7f62feca..43f5b49f 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java @@ -31,6 +31,18 @@ public interface ParcelRepository { CompletableFuture> findByReceiver(UUID receiver, Page page); + /** + * Finds the parcels a receiver is allowed to collect: those addressed to them whose status is + * {@link com.eternalcode.parcellockers.parcel.ParcelStatus#DELIVERED}. Filtering happens in the + * query so pagination operates on the eligible set rather than on a raw receiver page. + * + * @param receiver the receiver whose parcels are collected + * @param destinationLocker when non-null, only parcels addressed to this locker are returned; + * when null, delivered parcels from any locker are returned + * @param page the requested page + */ + CompletableFuture> findCollectible(UUID receiver, UUID destinationLocker, Page page); + /** * Counts the parcels currently occupying a destination locker. Collected parcels are removed * from storage, so every parcel addressed to the locker (in-transit or delivered) occupies a diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java index 5bdd549f..43c56008 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java @@ -4,6 +4,7 @@ import com.eternalcode.parcellockers.database.DatabaseManager; import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import java.util.List; @@ -17,6 +18,7 @@ public class ParcelRepositoryOrmLite extends AbstractRepositoryOrmLite implement private static final String RECEIVER_COLUMN = "receiver"; private static final String SENDER_COLUMN = "sender"; private static final String DESTINATION_LOCKER_COLUMN = "destination_locker"; + private static final String STATUS_COLUMN = "status"; public ParcelRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { super(databaseManager, scheduler); @@ -78,6 +80,22 @@ public CompletableFuture> findByReceiver(UUID receiver, Page return this.findByPaged(receiver, page, RECEIVER_COLUMN); } + @Override + public CompletableFuture> findCollectible(UUID receiver, UUID destinationLocker, Page page) { + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(page, "Page cannot be null"); + return this.queryPage(ParcelTable.class, page, builder -> { + var where = builder.where() + .eq(RECEIVER_COLUMN, receiver) + .and() + .eq(STATUS_COLUMN, ParcelStatus.DELIVERED); + if (destinationLocker != null) { + where.and().eq(DESTINATION_LOCKER_COLUMN, destinationLocker); + } + return builder; + }, ParcelTable::toParcel); + } + @Override public CompletableFuture countParcelsByDestinationLocker(UUID destinationLocker) { Objects.requireNonNull(destinationLocker, "Destination locker UUID cannot be null"); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java index e733114a..abaa9cb5 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java @@ -36,6 +36,14 @@ public interface ParcelService { CompletableFuture> getByReceiver(UUID receiver, Page page); + /** + * Returns the delivered parcels a receiver may collect, optionally restricted to a single + * destination locker. Filtering is applied in the query so pagination stays consistent. + * + * @param destinationLocker the locker to collect from, or null to allow any locker + */ + CompletableFuture> getCollectible(UUID receiver, UUID destinationLocker, Page page); + CompletableFuture> getAll(Page page); CompletableFuture delete(UUID uuid); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index 0ecde6da..27a687f8 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -309,6 +309,18 @@ public CompletableFuture> getByReceiver(UUID receiver, Page p }); } + @Override + public CompletableFuture> getCollectible(UUID receiver, UUID destinationLocker, Page page) { + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(page, "Page cannot be null"); + + return this.parcelRepository.findCollectible(receiver, destinationLocker, page) + .thenApply(result -> { + result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return result; + }); + } + @Override public CompletableFuture> getAll(Page page) { Objects.requireNonNull(page, "Page cannot be null"); diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java new file mode 100644 index 00000000..87a2da64 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java @@ -0,0 +1,110 @@ +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.shared.PageResult; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.UUID; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers(disabledWithoutDocker = true) +class ParcelFindCollectibleIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + private ParcelRepository repository() throws SQLException { + PluginConfig config = new PluginConfig(); + config.settings.databaseType = DatabaseType.MYSQL; + config.settings.host = mySQLContainer.getHost(); + config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); + config.settings.databaseName = mySQLContainer.getDatabaseName(); + config.settings.user = mySQLContainer.getUsername(); + config.settings.password = mySQLContainer.getPassword(); + + DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); + databaseManager.connect(); + this.databaseManager = databaseManager; + + return new ParcelRepositoryOrmLite(databaseManager, new TestScheduler()); + } + + private void save(ParcelRepository repository, UUID receiver, UUID destinationLocker, ParcelStatus status) { + this.await(repository.save(new Parcel( + UUID.randomUUID(), UUID.randomUUID(), "p", "d", false, + receiver, ParcelSize.SMALL, UUID.randomUUID(), destinationLocker, status))); + } + + @Test + void findCollectibleReturnsOnlyDeliveredParcelsForTheGivenLockerAndReceiver() throws SQLException { + ParcelRepository repository = this.repository(); + + UUID receiver = UUID.randomUUID(); + UUID locker = UUID.randomUUID(); + UUID otherLocker = UUID.randomUUID(); + + for (int i = 0; i < 3; i++) { + this.save(repository, receiver, locker, ParcelStatus.DELIVERED); + } + this.save(repository, receiver, locker, ParcelStatus.SENT); // not yet delivered + this.save(repository, receiver, otherLocker, ParcelStatus.DELIVERED); // different locker + this.save(repository, UUID.randomUUID(), locker, ParcelStatus.DELIVERED); // different receiver + + // Pagination must count only the 3 eligible parcels, not the raw receiver page. + PageResult firstPage = this.await(repository.findCollectible(receiver, locker, new Page(0, 2))); + assertEquals(2, firstPage.items().size()); + assertTrue(firstPage.hasNextPage()); + + PageResult secondPage = this.await(repository.findCollectible(receiver, locker, new Page(1, 2))); + assertEquals(1, secondPage.items().size()); + assertFalse(secondPage.hasNextPage()); + } + + @Test + void findCollectibleWithNullLockerReturnsDeliveredParcelsFromAnyLocker() throws SQLException { + ParcelRepository repository = this.repository(); + + UUID receiver = UUID.randomUUID(); + + this.save(repository, receiver, UUID.randomUUID(), ParcelStatus.DELIVERED); + this.save(repository, receiver, UUID.randomUUID(), ParcelStatus.DELIVERED); + this.save(repository, receiver, UUID.randomUUID(), ParcelStatus.SENT); // excluded by status + this.save(repository, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.DELIVERED); // other receiver + + PageResult page = this.await(repository.findCollectible(receiver, null, new Page(0, 10))); + assertEquals(2, page.items().size()); + assertFalse(page.hasNextPage()); + assertTrue(page.items().stream().allMatch(parcel -> parcel.receiver().equals(receiver))); + assertTrue(page.items().stream().allMatch(parcel -> parcel.status() == ParcelStatus.DELIVERED)); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +}