From 6a30af6cf9080a24955b4dded4fd488db6d7d18f Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:06:41 +0200 Subject: [PATCH 01/21] Add Admin GUI design doc (issue #64) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../specs/2026-06-21-admin-gui-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-admin-gui-design.md diff --git a/docs/superpowers/specs/2026-06-21-admin-gui-design.md b/docs/superpowers/specs/2026-06-21-admin-gui-design.md new file mode 100644 index 00000000..3edc8fa4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-admin-gui-design.md @@ -0,0 +1,194 @@ +# Admin GUI — Design + +**Issue:** #64 — "Admin GUI (deleting, overwriting parcels, parcel lockers management and modifying them)" +**Branch:** `feature/admin-gui` +**Date:** 2026-06-21 + +## Summary + +Add an in-game Admin GUI that lets a holder of `parcellockers.admin` browse and fully +modify every parcel, manage and rename lockers, inspect users, and run guarded bulk +deletions — all from inventory menus consistent with the plugin's existing +triumph-gui screens. Risky edits (parcel size, priority, receiver, destination, status) +are funneled through a dedicated service that enforces invariants (content fits the size, +destination locker not full) and gracefully updates delivery timing. + +## Goals + +- Single entry point: `/parcellockers admin` (reuses the existing `parcellockers.admin` permission). +- Full parcel modification: name, description, priority, size, status, receiver, destination, delete. +- Locker management: browse, rename, teleport, delete. +- User inspection: browse users and view their received/sent parcels (read-only). +- Bulk actions: delete-all parcels / delete-all lockers, each behind a confirmation dialog. +- Safe edits to **in-transit** parcels (no stale-snapshot overwrite by the delivery task). + +## Non-Goals + +- Editing parcel **item contents** (only metadata is editable in this version). +- A new top-level command or a separate permission node. +- Web/REST admin surface — in-game GUI only. +- Free-text editing via chat or anvil — input uses the Paper Dialog API only. + +## Background / Current State + +- Domains are layered: Model → Repository (ORMLite, returns `CompletableFuture`) → + Manager/Service → Controller. Manual DI in `ParcelLockers#onEnable`. +- GUIs implement `GuiView`, built with triumph-gui, items/titles sourced from + `PluginConfig.GuiSettings`, data loaded via `CompletableFuture` and reopened on the + main thread with `Scheduler#run`. `MainGui` / `ParcelListGui` are reference patterns. +- Free-text input already uses the Paper **Dialog API** (`SendingGui`, + `DiscordVerificationDialogFactory`, `LockerPlaceController`). +- `ParcelService` already exposes `update`, `delete(parcel)`, `deleteAll`, `get`, + `getBySender`, `getByReceiver`. There is **no** "get all parcels" path yet. +- `LockerManager` exposes `get`, `get(page)`, `create`, `delete`, `deleteAll`, but + **no rename/update** — `Locker(uuid, name, position)` is an immutable record. +- `UserManager#getPage` and `GuiManager#getUsers` already exist. + +### Size → capacity + +Content GUIs (`ItemStorageGui`) are 2/3/4 rows for SMALL/MEDIUM/LARGE, with the bottom +row reserved, giving **usable capacities of 9 / 18 / 27 item stacks**. This matches the +size-inference in `SendingGui`. Shrinking a parcel's size must reject when +`content.items().size() > capacity(newSize)`. + +### Priority → delivery time, and the stale-snapshot problem + +Delivery timing is set once at dispatch: `deliveryTimestamp = now + (priority ? +priorityParcelSendDuration : parcelSendDuration)`, stored in a `Delivery` row. +`ParcelSendTask` is scheduled via `Scheduler#runLaterAsync` with a fixed delay and +**captures a `Parcel` snapshot** at schedule time; on fire it marks that snapshot +`DELIVERED`. It does **not** re-read the parcel or the delivery timestamp, and no +cancellation handle is stored. Consequently, editing an in-transit (`SENT`) parcel today +would be silently reverted when the already-scheduled task fires with its stale snapshot. + +## Design + +### Entry point + +Add to `ParcelLockersCommand` (already `@Permission("parcellockers.admin")`): + +```java +@Execute(name = "admin") +void admin(@Sender Player player) { this.adminGui.show(player); } +``` + +`AdminGui` is constructed in `ParcelLockers#onEnable` alongside the other GUIs and +injected into the command. + +### GUI layer — new package `gui/implementation/admin/` + +All implement `GuiView`, follow the existing constructor-injection + `CompletableFuture` ++ `Scheduler#run` reopen pattern, hold a back-navigation reference to their parent, and +source every item/title from config. + +| Screen | Purpose | +|---|---| +| `AdminGui` | Root menu: Parcels, Lockers, Users, Bulk-delete parcels, Bulk-delete lockers, Close. | +| `AdminParcelListGui` | Paginated list of **all** parcels; click → `AdminParcelEditGui`. | +| `AdminParcelEditGui` | Per-field buttons (see below) + Delete + Back. | +| `AdminLockerListGui` | Paginated list of all lockers; click → `AdminLockerEditGui`. | +| `AdminLockerEditGui` | Rename (Dialog), Teleport, Delete, Back. | +| `AdminUserListGui` | Paginated users; click → `AdminUserInspectGui`. | +| `AdminUserInspectGui` | Read-only: that user's received & sent parcels (reuses existing parcel rendering). | + +`AdminParcelEditGui` buttons: + +- **Name**, **Description** — Paper Dialog text input (pattern from `SendingGui`). +- **Priority** — click-to-toggle boolean. +- **Size** — click-to-cycle `SMALL → MEDIUM → LARGE`. +- **Status** — click-to-cycle `ParcelStatus` (`SENT`/`DELIVERED`). +- **Receiver** — opens a user-picker sub-GUI. +- **Destination** — opens a locker-picker sub-GUI. +- **Delete** — confirmation Dialog, then delete. + +Bulk actions on `AdminGui` open a confirmation Dialog before calling the existing +`deleteAll` paths. + +### Service layer — `AdminParcelService` (new) + +Centralizes risky edits and their side effects so GUIs stay thin. All methods return +`CompletableFuture` where `EditResult` carries success or a typed failure +(e.g. `SIZE_TOO_SMALL`, `DESTINATION_FULL`) the GUI maps to a notice. + +- `changeName` / `changeDescription` — straight `ParcelService#update`. +- `changeSize(parcel, newSize)` — load `ParcelContent`; if + `items().size() > capacity(newSize)` → `SIZE_TOO_SMALL` (no write); else `update`. + `capacity`: SMALL=9, MEDIUM=18, LARGE=27. +- `changePriority(parcel, newPriority)` — `update`; then if the parcel is in-transit + (`SENT` and a `Delivery` exists), **shift** the timestamp by the duration delta: + `newTs = oldTs + (durationFor(newPriority) - durationFor(oldPriority))`, **clamped to + not before `now`** (an already-overdue shift delivers promptly, never retroactively), + persisted via `DeliveryManager`. +- `changeStatus(parcel, newStatus)` — `update`. (Combined with the task refactor below, + forcing `DELIVERED` cleanly resolves a stuck parcel.) +- `changeReceiver(parcel, receiver)` — `update`. +- `changeDestination(parcel, lockerUuid)` — re-check `LockerManager#isLockerFull`; + if full → `DESTINATION_FULL` (no write); else `update`. +- `delete(parcel)` — existing delete path. + +`durationFor(priority)` reads `priorityParcelSendDuration` / `parcelSendDuration` from +`PluginConfig`. + +### Locker rename + +Add `LockerManager#rename(UUID, String newName): CompletableFuture` backed by a +repository update, refreshing the `lockersByUUID` / `lockersByPosition` caches. Name is +validated via the existing `LockerValidationService` rules; position is unchanged. + +### `ParcelSendTask` refactor (Option A) + +At fire time the task re-fetches current state instead of trusting its constructor +snapshot: + +1. Load the current `Parcel` by uuid. If absent or already `DELIVERED`, no-op (and clean + up any stray `Delivery`). +2. Load the current `Delivery`. If its `deliveryTimestamp` is now in the future + (admin extended it / toggled priority), **reschedule** `this` for the new delay and + return. +3. Otherwise mark the **current** parcel `DELIVERED` (preserving any admin-edited fields), + fire `ParcelDeliverEvent`, then delete the delivery — same ordering/retry semantics as + today. + +Call sites (dispatch and the startup reschedule loop in `ParcelLockers#onEnable`) are +unchanged; only the task's internal behavior changes. This makes **every** admin edit to +an in-transit parcel safe — no stale-snapshot overwrite — and is what implements +"gracefully change delivery time." + +### Config & messages + +- `GuiSettings`: new `ConfigItem`s for every admin button/icon and new GUI titles. +- `MessageConfig.AdminMessages`: `parcelUpdated`, `sizeTooSmall`, `priorityUpdated`, + `lockerRenamed`, `teleported`, `destinationFull`, plus confirmation prompt strings. +- All user-facing text stays in config (existing convention). + +### Error handling + +- Repository failures flow through `FutureHandler::handleException`; invariant violations + (`SIZE_TOO_SMALL`, `DESTINATION_FULL`) abort the edit with no partial write and send a + notice. +- GUIs always reopen on the main thread via `Scheduler#run`. + +## Testing + +- **Unit — `AdminParcelService`:** size-capacity boundaries (9/18/27 exact-fit vs. + over-by-one), priority delta-shift incl. clamp-to-now for overdue, destination-full + rejection, status change. +- **Unit — `ParcelSendTask`:** reschedule when timestamp moved to the future; no-op when + parcel missing/already delivered; delivers current (edited) fields. +- **Integration — `LockerManager#rename`:** Testcontainers DB, following + `LockerRepositoryIntegrationTest`. + +## Implementation Phasing (single spec) + +1. **Foundation & low-risk areas:** `AdminGui` + command entry + wiring; locker + browse/rename/teleport/delete (`LockerManager#rename`); user inspect; bulk actions; + config/message scaffolding. +2. **Parcel editing & safety:** `AdminParcelService` with all invariants; + `AdminParcelListGui` + all-parcels query; `AdminParcelEditGui`; `ParcelSendTask` + refactor; tests. + +## Open Questions + +None outstanding. Decisions locked: reuse `parcellockers.admin`; Dialog API for input; +all parcel fields editable; size-fit + priority-graceful constraints; priority timing via +clamped delta-shift; Option A task refactor; one phased spec. From 42cd11a6bf14e52818b9a0bd5c70d70f0a16a389 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:12:22 +0200 Subject: [PATCH 02/21] Add parcel content editing to Admin GUI design (issue #64) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../specs/2026-06-21-admin-gui-design.md | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-06-21-admin-gui-design.md b/docs/superpowers/specs/2026-06-21-admin-gui-design.md index 3edc8fa4..ccaaecbb 100644 --- a/docs/superpowers/specs/2026-06-21-admin-gui-design.md +++ b/docs/superpowers/specs/2026-06-21-admin-gui-design.md @@ -16,7 +16,7 @@ destination locker not full) and gracefully updates delivery timing. ## Goals - Single entry point: `/parcellockers admin` (reuses the existing `parcellockers.admin` permission). -- Full parcel modification: name, description, priority, size, status, receiver, destination, delete. +- Full parcel modification: name, description, priority, size, status, receiver, destination, item contents, delete. - Locker management: browse, rename, teleport, delete. - User inspection: browse users and view their received/sent parcels (read-only). - Bulk actions: delete-all parcels / delete-all lockers, each behind a confirmation dialog. @@ -24,7 +24,6 @@ destination locker not full) and gracefully updates delivery timing. ## Non-Goals -- Editing parcel **item contents** (only metadata is editable in this version). - A new top-level command or a separate permission node. - Web/REST admin surface — in-game GUI only. - Free-text editing via chat or anvil — input uses the Paper Dialog API only. @@ -43,6 +42,13 @@ destination locker not full) and gracefully updates delivery timing. - `LockerManager` exposes `get`, `get(page)`, `create`, `delete`, `deleteAll`, but **no rename/update** — `Locker(uuid, name, position)` is an immutable record. - `UserManager#getPage` and `GuiManager#getUsers` already exist. +- **Parcel content is write-once.** `ParcelContentRepositoryOrmLite#save` uses + `insertIfAbsent` (no in-place update), and `ParcelContentManager` exposes only + `create` (throws if content already exists) and `delete` — content is written once at + send and removed at collect. Editing content therefore needs a new update/upsert path. +- `ItemStorageGui` already implements the full content-editing flow against a triumph-gui + `StorageGui` (rows by size, illegal-item filtering on close, persist) — the reference + pattern for the admin content editor. ### Size → capacity @@ -85,7 +91,8 @@ source every item/title from config. |---|---| | `AdminGui` | Root menu: Parcels, Lockers, Users, Bulk-delete parcels, Bulk-delete lockers, Close. | | `AdminParcelListGui` | Paginated list of **all** parcels; click → `AdminParcelEditGui`. | -| `AdminParcelEditGui` | Per-field buttons (see below) + Delete + Back. | +| `AdminParcelEditGui` | Per-field buttons (see below) + Edit-contents + Delete + Back. | +| `AdminParcelContentGui` | Prefilled `StorageGui` sized to the parcel; edit/persist item contents. | | `AdminLockerListGui` | Paginated list of all lockers; click → `AdminLockerEditGui`. | | `AdminLockerEditGui` | Rename (Dialog), Teleport, Delete, Back. | | `AdminUserListGui` | Paginated users; click → `AdminUserInspectGui`. | @@ -99,8 +106,16 @@ source every item/title from config. - **Status** — click-to-cycle `ParcelStatus` (`SENT`/`DELIVERED`). - **Receiver** — opens a user-picker sub-GUI. - **Destination** — opens a locker-picker sub-GUI. +- **Edit contents** — opens `AdminParcelContentGui`. - **Delete** — confirmation Dialog, then delete. +`AdminParcelContentGui` mirrors `ItemStorageGui`: a `StorageGui` whose rows match the +parcel's size (SMALL=2/MEDIUM=3/LARGE=4, usable 9/18/27), **pre-filled** with the +current `ParcelContent.items()`. On close it filters configured illegal items (returned +to the editing admin) and persists the remaining stacks via the new content update path. +Emptying all slots is allowed and results in empty `ParcelContent` (the parcel is not +auto-deleted). Capacity cannot be exceeded — the inventory has exactly the size's slots. + Bulk actions on `AdminGui` open a confirmation Dialog before calling the existing `deleteAll` paths. @@ -129,6 +144,20 @@ Centralizes risky edits and their side effects so GUIs stay thin. All methods re `durationFor(priority)` reads `priorityParcelSendDuration` / `parcelSendDuration` from `PluginConfig`. +### Parcel content update path + +The write-once content layer gains an update capability: + +- `ParcelContentManager#update(UUID parcelId, List items): CompletableFuture` + — replaces the cache entry and persists, overwriting any existing row. +- Backed by `ParcelContentRepository#update` (a true upsert, e.g. ORMLite + `createOrUpdate`) rather than the insert-if-absent `save`, so an existing parcel's + content is actually overwritten. + +`AdminParcelContentGui` calls `update` on close. The collect flow is unaffected (it still +reads-then-deletes); an edit racing a collect is a rare admin-action/player-action overlap +and resolves to last-writer-wins with no corruption (content is keyed by parcel uuid). + ### Locker rename Add `LockerManager#rename(UUID, String newName): CompletableFuture` backed by a @@ -177,6 +206,8 @@ an in-transit parcel safe — no stale-snapshot overwrite — and is what implem parcel missing/already delivered; delivers current (edited) fields. - **Integration — `LockerManager#rename`:** Testcontainers DB, following `LockerRepositoryIntegrationTest`. +- **Integration — `ParcelContentRepository#update`:** overwrites an existing row (insert + then update then re-read returns the new items), against the Testcontainers DB. ## Implementation Phasing (single spec) @@ -184,11 +215,13 @@ an in-transit parcel safe — no stale-snapshot overwrite — and is what implem browse/rename/teleport/delete (`LockerManager#rename`); user inspect; bulk actions; config/message scaffolding. 2. **Parcel editing & safety:** `AdminParcelService` with all invariants; - `AdminParcelListGui` + all-parcels query; `AdminParcelEditGui`; `ParcelSendTask` - refactor; tests. + `AdminParcelListGui` + all-parcels query; `AdminParcelEditGui`; parcel-content update + path (`ParcelContentManager#update` + repository upsert) and `AdminParcelContentGui`; + `ParcelSendTask` refactor; tests. ## Open Questions None outstanding. Decisions locked: reuse `parcellockers.admin`; Dialog API for input; -all parcel fields editable; size-fit + priority-graceful constraints; priority timing via -clamped delta-shift; Option A task refactor; one phased spec. +all parcel fields editable including item contents; size-fit + priority-graceful +constraints; priority timing via clamped delta-shift; Option A task refactor; one phased +spec. From ce5bc74c31d7e92631528bdf3fc9ac5f035a39d4 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:26:14 +0200 Subject: [PATCH 03/21] Add Admin GUI implementation plan (issue #64) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../superpowers/plans/2026-06-21-admin-gui.md | 2670 +++++++++++++++++ 1 file changed, 2670 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-21-admin-gui.md diff --git a/docs/superpowers/plans/2026-06-21-admin-gui.md b/docs/superpowers/plans/2026-06-21-admin-gui.md new file mode 100644 index 00000000..2d07966f --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-admin-gui.md @@ -0,0 +1,2670 @@ +# Admin GUI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an in-game Admin GUI (`/parcellockers admin`) that lets `parcellockers.admin` holders browse and fully modify every parcel (incl. item contents), manage lockers, inspect users, and run guarded bulk deletions. + +**Architecture:** New `gui/implementation/admin/` screens follow the existing `GuiView` + triumph-gui patterns. Risky parcel edits route through a new `AdminParcelService` that enforces invariants (content fits size, destination not full) and adjusts delivery timing. `ParcelSendTask` is refactored to re-fetch state at fire time so admin edits to in-transit parcels are never clobbered. The content and locker layers gain update/upsert paths they currently lack. + +**Tech Stack:** Java 21, Paper 1.21, triumph-gui, Paper Dialog API (unstable), ORMLite (H2/MySQL), okaeri-configs, LiteCommands, JUnit 5 + Testcontainers (MySQL). + +## Global Constraints + +- JDK 21+. Follow existing layering: Model → Repository (`CompletableFuture`) → Manager/Service → GUI/Controller. Manual DI in `ParcelLockers#onEnable`. +- All user-facing text comes from `MessageConfig`/`PluginConfig` (okaeri); never hardcode player-visible strings except transient dialog labels already done inline in `SendingGui`. +- All DB access returns `CompletableFuture`; reopen GUIs on the main thread via `Scheduler#run`. Route async failures through `com.eternalcode.commons.concurrent.FutureHandler::handleException`. +- Permission node: reuse `parcellockers.admin` (already on `ParcelLockersCommand`). +- Size→usable capacity: SMALL=9, MEDIUM=18, LARGE=27 item stacks. Content GUI rows: SMALL=2, MEDIUM=3, LARGE=4. +- Priority send durations: `config.settings.priorityParcelSendDuration`, `config.settings.parcelSendDuration`. +- Build: `./gradlew test` (single class: `./gradlew test --tests "FQCN"`). Java tests use the `@Testcontainers(disabledWithoutDocker = true)` + `IntegrationTestSpec` pattern for DB tests. +- Commit message footer (every commit): + ``` + Co-Authored-By: Claude Opus 4.8 + Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 + ``` + +--- + +## File Structure + +**New files:** +- `parcel/service/AdminParcelService.java` — risky parcel edits + invariants. +- `parcel/service/EditResult.java` — typed result of an admin edit. +- `gui/implementation/admin/AdminGui.java` — root menu + bulk actions. +- `gui/implementation/admin/AdminParcelListGui.java` — paginated all-parcels list. +- `gui/implementation/admin/AdminParcelEditGui.java` — per-field parcel editor. +- `gui/implementation/admin/AdminParcelContentGui.java` — prefilled content editor. +- `gui/implementation/admin/AdminReceiverPickerGui.java` — user picker for receiver. +- `gui/implementation/admin/AdminDestinationPickerGui.java` — locker picker for destination. +- `gui/implementation/admin/AdminLockerListGui.java` — paginated locker list. +- `gui/implementation/admin/AdminLockerEditGui.java` — rename/teleport/delete. +- `gui/implementation/admin/AdminUserListGui.java` — paginated user list. +- `gui/implementation/admin/AdminUserInspectGui.java` — read-only user parcels. +- `gui/implementation/admin/ConfirmationDialogFactory.java` — reusable confirm dialog. + +**Modified files:** +- `locker/repository/LockerRepository.java` (+`update`), `LockerRepositoryOrmLite.java`. +- `locker/LockerManager.java` (+`rename`). +- `content/repository/ParcelContentRepository.java` (+`update`), `ParcelContentRepositoryOrmLite.java`. +- `content/ParcelContentManager.java` (+`update`). +- `parcel/repository/ParcelRepository.java` (+`findPage`), `ParcelRepositoryOrmLite.java`. +- `parcel/service/ParcelService.java` (+`getAll`), `ParcelServiceImpl.java`. +- `gui/GuiManager.java` (+`getAllParcels`, +`updateParcelContent`, +`renameLocker`, +`teleport` helpers as needed). +- `parcel/task/ParcelSendTask.java` (re-fetch refactor; new constructor deps). +- `configuration/implementation/PluginConfig.java` (`GuiSettings` items/titles). +- `configuration/implementation/MessageConfig.java` (`AdminMessages` entries). +- `ParcelLockersCommand.java` (+`admin` subcommand). +- `ParcelLockers.java` (wire `AdminParcelService`, `AdminGui`; pass to command; pass `parcelService`/`deliveryManager` deps already available to `ParcelSendTask` call sites). + +--- + +## PHASE 1 — Foundation & low-risk areas + +### Task 1: Locker rename (repository + manager) + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepository.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java` +- Test: `src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java` + +**Interfaces:** +- Produces: `LockerRepository#update(Locker): CompletableFuture`; `LockerManager#rename(UUID, String): CompletableFuture` (resolves the locker, persists a new `Locker(uuid, newName, position)`, refreshes both caches; completes exceptionally with `IllegalArgumentException` if the locker does not exist). + +- [ ] **Step 1: Write the failing integration test** + +Create `LockerRenameIntegrationTest.java`: + +```java +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.locker.repository.LockerRepository; +import com.eternalcode.parcellockers.locker.repository.LockerRepositoryOrmLite; +import com.eternalcode.parcellockers.shared.Position; +import java.nio.file.Path; +import java.util.Optional; +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 LockerRenameIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + @Test + void updateOverwritesExistingName() { + 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; + + LockerRepository repository = new LockerRepositoryOrmLite(databaseManager, new TestScheduler()); + UUID uuid = UUID.randomUUID(); + Position position = new Position(1, 2, 3, "world"); + this.await(repository.save(new Locker(uuid, "Old name", position))); + + this.await(repository.update(new Locker(uuid, "New name", position))); + + Optional reloaded = this.await(repository.find(uuid)); + assertTrue(reloaded.isPresent()); + assertEquals("New name", reloaded.get().name()); + assertEquals(position, reloaded.get().position()); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `./gradlew test --tests "com.eternalcode.parcellockers.database.LockerRenameIntegrationTest"` +Expected: compile failure — `LockerRepository#update` does not exist. + +- [ ] **Step 3: Add `update` to the repository interface** + +In `LockerRepository.java`, add below `save`: + +```java + CompletableFuture update(Locker locker); +``` + +- [ ] **Step 4: Implement `update` in the ORMLite repository** + +In `LockerRepositoryOrmLite.java`, add after `save`: + +```java + @Override + public CompletableFuture update(Locker locker) { + return this.upsert(LockerTable.class, LockerTable.from(locker)).thenApply(status -> locker); + } +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `./gradlew test --tests "com.eternalcode.parcellockers.database.LockerRenameIntegrationTest"` +Expected: PASS (or SKIPPED if Docker is unavailable — then verify compilation with `./gradlew compileTestJava`). + +- [ ] **Step 6: Add `rename` to `LockerManager`** + +In `LockerManager.java`, add (uses existing `lockerRepository`, `lockersByUUID`, `lockersByPosition`, `validationService`): + +```java + public CompletableFuture rename(UUID uniqueId, String newName) { + return this.get(uniqueId).thenCompose(optional -> { + if (optional.isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Locker not found: " + uniqueId)); + } + + Locker existing = optional.get(); + Locker renamed = new Locker(existing.uuid(), newName, existing.position()); + + return this.lockerRepository.update(renamed).thenApply(saved -> { + this.lockersByUUID.put(saved.uuid(), saved); + this.lockersByPosition.put(saved.position(), saved); + return saved; + }); + }); + } +``` + +- [ ] **Step 7: Verify compilation** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 8: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/locker/ src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java +git commit -m "feat: add locker rename (repository update + manager rename)" +``` + +--- + +### Task 2: Parcel content update path (repository + manager) + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepository.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java` +- Test: `src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java` + +**Interfaces:** +- Produces: `ParcelContentRepository#update(ParcelContent): CompletableFuture` (upsert); `ParcelContentManager#update(UUID, List): CompletableFuture` (replaces cache entry + persists). + +- [ ] **Step 1: Write the failing integration test** + +Create `ParcelContentUpdateIntegrationTest.java`: + +```java +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.content.repository.ParcelContentRepository; +import com.eternalcode.parcellockers.content.repository.ParcelContentRepositoryOrmLite; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +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 ParcelContentUpdateIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + @Test + void updateOverwritesExistingContent() { + 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; + + ParcelContentRepository repository = new ParcelContentRepositoryOrmLite(databaseManager, new TestScheduler()); + UUID parcel = UUID.randomUUID(); + this.await(repository.save(new ParcelContent(parcel, List.of(new ItemStack(Material.STONE, 1))))); + + this.await(repository.update(new ParcelContent(parcel, List.of(new ItemStack(Material.DIAMOND, 5))))); + + Optional reloaded = this.await(repository.find(parcel)); + assertTrue(reloaded.isPresent()); + assertEquals(1, reloaded.get().items().size()); + assertEquals(Material.DIAMOND, reloaded.get().items().getFirst().getType()); + assertEquals(5, reloaded.get().items().getFirst().getAmount()); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `./gradlew test --tests "com.eternalcode.parcellockers.database.ParcelContentUpdateIntegrationTest"` +Expected: compile failure — `ParcelContentRepository#update` does not exist. + +- [ ] **Step 3: Add `update` to the repository interface** + +In `ParcelContentRepository.java`, add below `save`: + +```java + CompletableFuture update(ParcelContent parcelContent); +``` + +- [ ] **Step 4: Implement `update` in the ORMLite repository** + +In `ParcelContentRepositoryOrmLite.java`, add after `save`: + +```java + @Override + public CompletableFuture update(ParcelContent parcelContent) { + return this.upsert(ParcelContentTable.class, ParcelContentTable.from(parcelContent)).thenApply(status -> null); + } +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `./gradlew test --tests "com.eternalcode.parcellockers.database.ParcelContentUpdateIntegrationTest"` +Expected: PASS (or SKIPPED without Docker — then `./gradlew compileTestJava`). + +- [ ] **Step 6: Add `update` to `ParcelContentManager`** + +In `ParcelContentManager.java`, add: + +```java + public CompletableFuture update(UUID parcel, List items) { + ParcelContent content = new ParcelContent(parcel, items); + return this.contentRepository.update(content).thenApply(ignored -> { + this.cache.put(parcel, content); + return content; + }); + } +``` + +- [ ] **Step 7: Verify compilation** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 8: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/content/ src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java +git commit -m "feat: add parcel content update/upsert path" +``` + +--- + +### Task 3: All-parcels pagination (repository + service + GuiManager) + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java` +- Test: `src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java` + +**Interfaces:** +- Produces: `ParcelRepository#findPage(Page): CompletableFuture>`; `ParcelService#getAll(Page): CompletableFuture>`; `GuiManager#getAllParcels(Page): CompletableFuture>`. + +- [ ] **Step 1: Write the failing integration test** + +Create `ParcelFindPageIntegrationTest.java`: + +```java +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.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 ParcelFindPageIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + @Test + void findPageReturnsAllParcelsAcrossSenders() { + 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; + + ParcelRepository repository = new ParcelRepositoryOrmLite(databaseManager, new TestScheduler()); + for (int i = 0; i < 3; i++) { + this.await(repository.save(new Parcel( + UUID.randomUUID(), UUID.randomUUID(), "p" + i, "d", false, + UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.SENT))); + } + + PageResult firstPage = this.await(repository.findPage(new Page(0, 2))); + assertEquals(2, firstPage.items().size()); + assertTrue(firstPage.hasNextPage()); + + PageResult secondPage = this.await(repository.findPage(new Page(1, 2))); + assertEquals(1, secondPage.items().size()); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `./gradlew test --tests "com.eternalcode.parcellockers.database.ParcelFindPageIntegrationTest"` +Expected: compile failure — `ParcelRepository#findPage` does not exist. + +- [ ] **Step 3: Add `findPage` to the repository interface** + +In `ParcelRepository.java`, add below `findAll`: + +```java + CompletableFuture> findPage(Page page); +``` + +- [ ] **Step 4: Implement `findPage` in the ORMLite repository** + +In `ParcelRepositoryOrmLite.java`, add after `findAll`: + +```java + @Override + public CompletableFuture> findPage(Page page) { + Objects.requireNonNull(page, "Page cannot be null"); + return this.queryPage(ParcelTable.class, page, builder -> builder, ParcelTable::toParcel); + } +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `./gradlew test --tests "com.eternalcode.parcellockers.database.ParcelFindPageIntegrationTest"` +Expected: PASS (or SKIPPED without Docker — then `./gradlew compileTestJava`). + +- [ ] **Step 6: Add `getAll` to the service interface + impl** + +In `ParcelService.java`, add below `getByReceiver`: + +```java + CompletableFuture> getAll(Page page); +``` + +In `ParcelServiceImpl.java`, add after `getByReceiver`: + +```java + @Override + public CompletableFuture> getAll(Page page) { + Objects.requireNonNull(page, "Page cannot be null"); + return this.parcelRepository.findPage(page) + .thenApply(result -> { + result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return result; + }); + } +``` + +- [ ] **Step 7: Add `getAllParcels` to `GuiManager`** + +In `GuiManager.java`, add after `getParcelsBySender`: + +```java + public CompletableFuture> getAllParcels(Page page) { + return this.parcelService.getAll(page); + } +``` + +- [ ] **Step 8: Verify compilation** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 9: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/parcel/ src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java +git commit -m "feat: add all-parcels pagination (repo findPage + service getAll)" +``` + +--- + +### Task 4: Config items + admin messages + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java` (inside `GuiSettings`, after `cornerItem` ~line 176) +- Modify: `src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java` (inside `AdminMessages`, after line 183) + +**Interfaces:** +- Produces: config fields read by all admin GUIs and `AdminParcelService`. Exact field names listed below — later tasks reference these verbatim. + +- [ ] **Step 1: Add admin GUI titles + items to `GuiSettings`** + +In `PluginConfig.java`, inside `class GuiSettings`, after the `cornerItem` field, add: + +```java + @Comment({ "", "# ----- Admin GUI -----" }) + @Comment("# Title of the admin root menu") + public String adminGuiTitle = "&4Admin panel"; + public String adminParcelListGuiTitle = "&4Admin: parcels"; + public String adminParcelEditGuiTitle = "&4Admin: edit parcel"; + public String adminLockerListGuiTitle = "&4Admin: lockers"; + public String adminLockerEditGuiTitle = "&4Admin: edit locker"; + public String adminUserListGuiTitle = "&4Admin: users"; + public String adminUserInspectGuiTitle = "&4Admin: user parcels"; + public String adminParcelContentGuiTitle = "&4Admin: edit contents"; + + @Comment({ "", "# Admin root menu buttons" }) + public ConfigItem adminParcelsButton = new ConfigItem() + .type(Material.CHEST) + .name("&6📦 &eParcels") + .lore(List.of("&7» &fBrowse and edit every parcel.")); + public ConfigItem adminLockersButton = new ConfigItem() + .type(Material.ENDER_CHEST) + .name("&6🔒 &eLockers") + .lore(List.of("&7» &fManage parcel lockers.")); + public ConfigItem adminUsersButton = new ConfigItem() + .type(Material.PLAYER_HEAD) + .name("&6👤 &eUsers") + .lore(List.of("&7» &fInspect users and their parcels.")); + public ConfigItem adminDeleteAllParcelsButton = new ConfigItem() + .type(Material.TNT) + .name("&4⚠ &cDelete ALL parcels") + .lore(List.of("&c» &7Irreversible. Asks for confirmation.")); + public ConfigItem adminDeleteAllLockersButton = new ConfigItem() + .type(Material.TNT) + .name("&4⚠ &cDelete ALL lockers") + .lore(List.of("&c» &7Irreversible. Asks for confirmation.")); + + @Comment({ "", "# Admin parcel edit buttons" }) + public ConfigItem adminEditNameButton = new ConfigItem() + .type(Material.NAME_TAG).name("&eName: &f{NAME}").lore(List.of("&7» &fClick to edit.")); + public ConfigItem adminEditDescriptionButton = new ConfigItem() + .type(Material.WRITABLE_BOOK).name("&eDescription").lore(List.of("&f{DESCRIPTION}", "&7» &fClick to edit.")); + public ConfigItem adminEditPriorityButton = new ConfigItem() + .type(Material.BLAZE_POWDER).name("&ePriority: &f{PRIORITY}").lore(List.of("&7» &fClick to toggle.")); + public ConfigItem adminEditSizeButton = new ConfigItem() + .type(Material.SHULKER_BOX).name("&eSize: &f{SIZE}").lore(List.of("&7» &fClick to cycle.")); + public ConfigItem adminEditStatusButton = new ConfigItem() + .type(Material.COMPARATOR).name("&eStatus: &f{STATUS}").lore(List.of("&7» &fClick to cycle.")); + public ConfigItem adminEditReceiverButton = new ConfigItem() + .type(Material.PLAYER_HEAD).name("&eReceiver: &f{RECEIVER}").lore(List.of("&7» &fClick to choose.")); + public ConfigItem adminEditDestinationButton = new ConfigItem() + .type(Material.ENDER_CHEST).name("&eDestination: &f{DESTINATION}").lore(List.of("&7» &fClick to choose.")); + public ConfigItem adminEditContentsButton = new ConfigItem() + .type(Material.CHEST_MINECART).name("&eEdit contents").lore(List.of("&7» &fOpen the item editor.")); + public ConfigItem adminDeleteParcelButton = new ConfigItem() + .type(Material.LAVA_BUCKET).name("&cDelete parcel").lore(List.of("&c» &7Asks for confirmation.")); + + @Comment({ "", "# Admin locker edit buttons" }) + public ConfigItem adminRenameLockerButton = new ConfigItem() + .type(Material.NAME_TAG).name("&eName: &f{NAME}").lore(List.of("&7» &fClick to rename.")); + public ConfigItem adminTeleportLockerButton = new ConfigItem() + .type(Material.ENDER_PEARL).name("&eTeleport").lore(List.of("&7» &fGo to this locker.")); + public ConfigItem adminDeleteLockerButton = new ConfigItem() + .type(Material.LAVA_BUCKET).name("&cDelete locker").lore(List.of("&c» &7Asks for confirmation.")); + + @Comment({ "", "# Admin list row items" }) + public ConfigItem adminParcelRowItem = new ConfigItem() + .type(Material.PAPER).name("&e{NAME}") + .lore(List.of("&7Status: &f{STATUS}", "&7Size: &f{SIZE}", "&7Priority: &f{PRIORITY}", "&8{UUID}", "&7» &fClick to edit.")); + public ConfigItem adminLockerRowItem = new ConfigItem() + .type(Material.CHEST).name("&e{NAME}") + .lore(List.of("&7{POSITION}", "&8{UUID}", "&7» &fClick to manage.")); + public ConfigItem adminUserRowItem = new ConfigItem() + .type(Material.PLAYER_HEAD).name("&e{NAME}") + .lore(List.of("&8{UUID}", "&7» &fClick to inspect.")); + public ConfigItem adminEmptyListItem = new ConfigItem() + .type(Material.BARRIER).name("&cNothing to show"); +``` + +(Add `import org.bukkit.Material;` to the file's imports if not already present.) + +- [ ] **Step 2: Add admin notices to `AdminMessages`** + +In `MessageConfig.java`, inside `class AdminMessages`, after `deletedDeliveries`, add: + +```java + public Notice parcelUpdated = Notice.chat("&2✔ &aParcel updated."); + public Notice parcelDeleted = Notice.chat("&2✔ &aParcel deleted."); + public Notice lockerRenamed = Notice.chat("&2✔ &aLocker renamed."); + public Notice lockerDeleted = Notice.chat("&2✔ &aLocker deleted."); + public Notice teleported = Notice.chat("&2✔ &aTeleported to the locker."); + public Notice teleportWorldMissing = Notice.chat("&4✘ &cThat locker's world is not loaded."); + public Notice sizeTooSmall = Notice.chat("&4✘ &cThe parcel's contents do not fit in that size."); + public Notice destinationFull = Notice.chat("&4✘ &cThat destination locker is full."); + public Notice contentsUpdated = Notice.chat("&2✔ &aParcel contents updated."); + public Notice priorityUpdated = Notice.chat("&2✔ &aPriority updated and delivery time adjusted."); + public Notice noPermission = Notice.chat("&4✘ &cYou do not have permission to do that."); +``` + +- [ ] **Step 3: Verify compilation + config loads** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/configuration/ +git commit -m "feat: add admin GUI config items and admin messages" +``` + +--- + +### Task 5: Confirmation dialog factory + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java` + +**Interfaces:** +- Produces: `ConfirmationDialogFactory(MiniMessage)` with `Dialog create(String titleMiniMessage, String confirmLabel, String cancelLabel, Runnable onConfirm, Runnable onCancel)`; show via `player.showDialog(dialog)`. + +- [ ] **Step 1: Create the factory (no test — thin UI wrapper, validated via the GUIs that use it)** + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.dialog.DialogResponseView; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.minimessage.MiniMessage; + +@SuppressWarnings("UnstableApiUsage") +class ConfirmationDialogFactory { + + private final MiniMessage miniMessage; + + ConfirmationDialogFactory(MiniMessage miniMessage) { + this.miniMessage = miniMessage; + } + + Dialog create(String title, String confirmLabel, String cancelLabel, Runnable onConfirm, Runnable onCancel) { + return Dialog.create(builder -> builder.empty() + .base(DialogBase.builder(this.miniMessage.deserialize(title)) + .canCloseWithEscape(true) + .build()) + .type(DialogType.confirmation( + ActionButton.create( + this.miniMessage.deserialize(confirmLabel), + null, + 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> onConfirm.run(), + ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build())), + ActionButton.create( + this.miniMessage.deserialize(cancelLabel), + null, + 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> onCancel.run(), + ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build()))))); + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java +git commit -m "feat: add reusable confirmation dialog factory for admin GUI" +``` + +--- + +### Task 6: Locker list + edit GUIs + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerListGui.java` +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java` + +**Interfaces:** +- Consumes: `GuiManager#getLockerPage(Page)`, `LockerManager#rename`, `LockerManager#delete`. Add thin `GuiManager` passthroughs in Step 1. +- Produces: `new AdminLockerListGui(scheduler, miniMessage, guiSettings, messageConfig, noticeService, guiManager, lockerManager, parent).show(player)`. + +- [ ] **Step 1: Add `GuiManager` passthroughs for locker rename/delete** + +In `GuiManager.java`, add a `LockerManager` accessor is unnecessary — instead add: + +```java + public CompletableFuture renameLocker(UUID lockerUuid, String newName) { + return this.lockerManager.rename(lockerUuid, newName); + } + + public CompletableFuture deleteLocker(UUID lockerUuid, UUID actor) { + return this.lockerManager.delete(lockerUuid, actor); + } +``` + +- [ ] **Step 2: Create `AdminLockerEditGui`** + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.shared.Position; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.dialog.DialogResponseView; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.input.DialogInput; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import java.util.List; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; + +@SuppressWarnings("UnstableApiUsage") +public class AdminLockerEditGui implements GuiView { + + private static final int RENAME_SLOT = 20; + private static final int TELEPORT_SLOT = 22; + private static final int DELETE_SLOT = 24; + private static final int CLOSE_SLOT = 49; + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final MessageConfig messageConfig; + private final NoticeService noticeService; + private final GuiManager guiManager; + private final AdminLockerListGui parent; + private final Locker locker; + private final ConfirmationDialogFactory confirmationDialogFactory; + + public AdminLockerEditGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + MessageConfig messageConfig, NoticeService noticeService, GuiManager guiManager, + AdminLockerListGui parent, Locker locker) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.messageConfig = messageConfig; + this.noticeService = noticeService; + this.guiManager = guiManager; + this.parent = parent; + this.locker = locker; + this.confirmationDialogFactory = new ConfirmationDialogFactory(miniMessage); + } + + @Override + public void show(Player player) { + Gui gui = Gui.gui() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminLockerEditGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + for (int i = 0; i < gui.getRows() * 9; i++) { + gui.setItem(i, background); + } + + ConfigItem renameItem = this.guiSettings.adminRenameLockerButton.clone(); + renameItem.name(this.guiSettings.adminRenameLockerButton.name().replace("{NAME}", this.locker.name())); + gui.setItem(RENAME_SLOT, renameItem.toGuiItem(event -> this.openRenameDialog(player))); + + gui.setItem(TELEPORT_SLOT, this.guiSettings.adminTeleportLockerButton.toGuiItem(event -> this.teleport(player, gui))); + + gui.setItem(DELETE_SLOT, this.guiSettings.adminDeleteLockerButton.toGuiItem(event -> + player.showDialog(this.confirmationDialogFactory.create( + "Delete locker '" + this.locker.name() + "'?", + "Delete", "Cancel", + () -> this.guiManager.deleteLocker(this.locker.uuid(), player.getUniqueId()).thenRun(() -> { + this.noticeService.create().notice(m -> m.admin.lockerDeleted).player(player.getUniqueId()).send(); + this.scheduler.run(() -> this.parent.show(player)); + }).exceptionally(FutureHandler::handleException), + () -> this.scheduler.run(() -> this.show(player)))))); + + gui.setItem(CLOSE_SLOT, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + gui.setDefaultClickAction(event -> event.setCancelled(true)); + this.scheduler.run(() -> gui.open(player)); + } + + private void teleport(Player player, Gui gui) { + Position position = this.locker.position(); + World world = Bukkit.getWorld(position.world()); + if (world == null) { + this.noticeService.create().notice(m -> m.admin.teleportWorldMissing).player(player.getUniqueId()).send(); + return; + } + gui.close(player); + this.scheduler.run(() -> { + player.teleport(new Location(world, position.x() + 0.5, position.y(), position.z() + 0.5)); + this.noticeService.create().notice(m -> m.admin.teleported).player(player.getUniqueId()).send(); + }); + } + + private void openRenameDialog(Player player) { + Dialog dialog = Dialog.create(builder -> builder.empty() + .base(DialogBase.builder(this.miniMessage.deserialize("Enter new locker name:")) + .inputs(List.of(DialogInput.text("name", this.miniMessage.deserialize("Locker name")).build())) + .build()) + .type(DialogType.confirmation( + ActionButton.create(this.miniMessage.deserialize("Confirm"), null, 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> { + String name = view.getText("name"); + if (name == null || name.isBlank()) { + this.scheduler.run(() -> this.show(player)); + return; + } + this.guiManager.renameLocker(this.locker.uuid(), name).thenAccept(renamed -> { + this.noticeService.create().notice(m -> m.admin.lockerRenamed).player(player.getUniqueId()).send(); + this.scheduler.run(() -> + new AdminLockerEditGui(this.scheduler, this.miniMessage, this.guiSettings, + this.messageConfig, this.noticeService, this.guiManager, this.parent, renamed).show(player)); + }).exceptionally(FutureHandler::handleException); + }, ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build())), + ActionButton.create(this.miniMessage.deserialize("Cancel"), null, 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> + this.scheduler.run(() -> this.show(player)), + ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build()))))); + player.showDialog(dialog); + } +} +``` + +- [ ] **Step 3: Create `AdminLockerListGui`** (paginated, mirrors `ParcelListGui`) + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.shared.Page; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.util.List; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminLockerListGui implements GuiView { + + private static final int WIDTH = 7; + private static final int HEIGHT = 4; + private static final Page FIRST_PAGE = new Page(0, WIDTH * HEIGHT); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final MessageConfig messageConfig; + private final NoticeService noticeService; + private final GuiManager guiManager; + private final GuiView parent; + + public AdminLockerListGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + MessageConfig messageConfig, NoticeService noticeService, GuiManager guiManager, GuiView parent) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.messageConfig = messageConfig; + this.noticeService = noticeService; + this.guiManager = guiManager; + this.parent = parent; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminLockerListGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + this.guiManager.getLockerPage(page).thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (Locker locker : result.items()) { + gui.addItem(this.createRow(player, locker)); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } + + private GuiItem createRow(Player player, Locker locker) { + ConfigItem row = this.guiSettings.adminLockerRowItem.clone(); + List lore = row.lore().stream() + .map(line -> line.replace("{POSITION}", locker.position().toString()).replace("{UUID}", locker.uuid().toString())) + .toList(); + return row.name(row.name().replace("{NAME}", locker.name())).lore(lore).toGuiItem(event -> + new AdminLockerEditGui(this.scheduler, this.miniMessage, this.guiSettings, this.messageConfig, + this.noticeService, this.guiManager, this, locker).show(player)); + } +} +``` + +- [ ] **Step 4: Verify compilation** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLocker*.java src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +git commit -m "feat: add admin locker list + edit GUIs (rename, teleport, delete)" +``` + +--- + +### Task 7: User list + inspect GUIs + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserListGui.java` +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java` + +**Interfaces:** +- Consumes: `GuiManager#getUsers(Page)`, `GuiManager#getParcelsByReceiver(UUID, Page)`, `GuiManager#getParcelsBySender(UUID, Page)`. +- Produces: `new AdminUserListGui(scheduler, miniMessage, guiSettings, messageConfig, guiManager, parent).show(player)`. + +- [ ] **Step 1: Create `AdminUserInspectGui`** (read-only; received parcels page, with a toggle to sent) + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.user.User; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.util.List; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminUserInspectGui implements GuiView { + + private static final Page FIRST_PAGE = new Page(0, 28); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final GuiManager guiManager; + private final AdminUserListGui parent; + private final User user; + private final boolean showSent; + + public AdminUserInspectGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + GuiManager guiManager, AdminUserListGui parent, User user, boolean showSent) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.guiManager = guiManager; + this.parent = parent; + this.user = user; + this.showSent = showSent; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminUserInspectGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + var future = this.showSent + ? this.guiManager.getParcelsBySender(this.user.uuid(), page) + : this.guiManager.getParcelsByReceiver(this.user.uuid(), page); + + future.thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (Parcel parcel : result.items()) { + gui.addItem(this.createRow(parcel)); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } + + private GuiItem createRow(Parcel parcel) { + ConfigItem row = this.guiSettings.adminParcelRowItem.clone(); + List lore = row.lore().stream() + .map(line -> line + .replace("{STATUS}", parcel.status().name()) + .replace("{SIZE}", parcel.size().name()) + .replace("{PRIORITY}", parcel.priority() ? "Yes" : "No") + .replace("{UUID}", parcel.uuid().toString())) + .toList(); + return row.name(row.name().replace("{NAME}", parcel.name())).lore(lore).toGuiItem(); + } +} +``` + +- [ ] **Step 2: Create `AdminUserListGui`** + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.user.User; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminUserListGui implements GuiView { + + private static final Page FIRST_PAGE = new Page(0, 28); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final GuiManager guiManager; + private final GuiView parent; + + public AdminUserListGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + GuiManager guiManager, GuiView parent) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.guiManager = guiManager; + this.parent = parent; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminUserListGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + this.guiManager.getUsers(page).thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (User user : result.items()) { + gui.addItem(this.createRow(player, user)); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } + + private GuiItem createRow(Player player, User user) { + ConfigItem row = this.guiSettings.adminUserRowItem.clone(); + return row.name(row.name().replace("{NAME}", user.name())) + .lore(row.lore().stream().map(line -> line.replace("{UUID}", user.uuid().toString())).toList()) + .toGuiItem(event -> new AdminUserInspectGui(this.scheduler, this.miniMessage, this.guiSettings, + this.guiManager, this, user, false).show(player)); + } +} +``` + +- [ ] **Step 3: Verify compilation** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUser*.java +git commit -m "feat: add admin user list + inspect GUIs" +``` + +--- + +## PHASE 2 — Parcel editing & safety + +### Task 8: `AdminParcelService` (invariants + side effects) + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepository.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java` +- Create: `src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java` +- Create: `src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java` +- Test: `src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java` + +**Interfaces:** +- Produces (delivery update path — `DeliveryManager#create` THROWS on an existing delivery and `DeliveryRepository#save` is insert-if-absent, so a new path is required): + - `DeliveryRepository#update(Delivery): CompletableFuture` (upsert). + - `DeliveryManager#update(UUID, Instant): CompletableFuture` (replaces cache + persists). +- Produces: + - `EditResult` — `enum Status { OK, SIZE_TOO_SMALL, DESTINATION_FULL }`; record-like with `static EditResult ok()`, `static EditResult of(Status)`, `Status status()`, `boolean isOk()`. + - `AdminParcelService(parcelService, parcelContentManager, deliveryManager, lockerManager, config)` with: + - `static int capacity(ParcelSize)` → 9/18/27. + - `CompletableFuture changeSize(Parcel, ParcelSize)`. + - `CompletableFuture changePriority(Parcel, boolean)`. + - `CompletableFuture changeStatus(Parcel, ParcelStatus)`. + - `CompletableFuture changeReceiver(Parcel, UUID)`. + - `CompletableFuture changeDestination(Parcel, UUID)`. + - `CompletableFuture changeName(Parcel, String)`. + - `CompletableFuture changeDescription(Parcel, String)`. + - `static Instant shiftedDeliveryTimestamp(Instant old, boolean oldPriority, boolean newPriority, Duration normal, Duration priority, Instant now)` — pure helper for the delta-shift, clamped to `now`. + +- [ ] **Step 0: Add the delivery update path** + +In `DeliveryRepository.java`, add below `save`: + +```java + CompletableFuture update(Delivery delivery); +``` + +In `DeliveryRepositoryOrmLite.java`, add after `save`: + +```java + @Override + public CompletableFuture update(Delivery delivery) { + return this.upsert(DeliveryTable.class, DeliveryTable.from(delivery)).thenApply(status -> null); + } +``` + +In `DeliveryManager.java`, add (next to `create`; note `create` deliberately throws on an existing delivery, so admin re-timing must use `update`): + +```java + public CompletableFuture update(UUID parcel, Instant deliveryTimestamp) { + Delivery delivery = new Delivery(parcel, deliveryTimestamp); + return this.deliveryRepository.update(delivery).thenApply(ignored -> { + this.deliveryCache.put(parcel, delivery); + return delivery; + }); + } +``` + +Verify compilation: `./gradlew compileJava` → BUILD SUCCESSFUL. + +- [ ] **Step 1: Create `EditResult`** + +```java +package com.eternalcode.parcellockers.parcel.service; + +public final class EditResult { + + public enum Status { OK, SIZE_TOO_SMALL, DESTINATION_FULL } + + private final Status status; + + private EditResult(Status status) { + this.status = status; + } + + public static EditResult ok() { + return new EditResult(Status.OK); + } + + public static EditResult of(Status status) { + return new EditResult(status); + } + + public Status status() { + return this.status; + } + + public boolean isOk() { + return this.status == Status.OK; + } +} +``` + +- [ ] **Step 2: Write the failing unit test** + +Create `AdminParcelServiceTest.java`. These cover the pure helpers (`capacity`, `shiftedDeliveryTimestamp`) which need no Bukkit/DB: + +```java +package com.eternalcode.parcellockers.parcel.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.eternalcode.parcellockers.parcel.ParcelSize; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class AdminParcelServiceTest { + + @Test + void capacityMatchesContentGuiUsableSlots() { + assertEquals(9, AdminParcelService.capacity(ParcelSize.SMALL)); + assertEquals(18, AdminParcelService.capacity(ParcelSize.MEDIUM)); + assertEquals(27, AdminParcelService.capacity(ParcelSize.LARGE)); + } + + @Test + void enablingPriorityShortensDeliveryByDelta() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofMinutes(5)); // normal delivery scheduled in 5 min + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, false, true, normal, priority, now); + + // delta = priority - normal = -4 min; oldTs - 4 min = now + 1 min + assertEquals(now.plus(Duration.ofMinutes(1)), shifted); + } + + @Test + void disablingPriorityExtendsDeliveryByDelta() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofMinutes(1)); + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, true, false, normal, priority, now); + + // delta = normal - priority = +4 min; oldTs + 4 min = now + 5 min + assertEquals(now.plus(Duration.ofMinutes(5)), shifted); + } + + @Test + void overdueShiftIsClampedToNow() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofSeconds(30)); // 30s left + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + // enabling priority: delta = -4 min, oldTs - 4 min is in the past -> clamp to now + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, false, true, normal, priority, now); + + assertEquals(now, shifted); + } + + @Test + void unchangedPriorityKeepsTimestamp() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofMinutes(3)); + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, true, true, normal, priority, now); + + assertEquals(oldTs, shifted); + } +} +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `./gradlew test --tests "com.eternalcode.parcellockers.parcel.service.AdminParcelServiceTest"` +Expected: compile failure — `AdminParcelService` does not exist. + +- [ ] **Step 4: Create `AdminParcelService`** + +```java +package com.eternalcode.parcellockers.parcel.service; + +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContentManager; +import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.locker.LockerManager; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class AdminParcelService { + + private final ParcelService parcelService; + private final ParcelContentManager parcelContentManager; + private final DeliveryManager deliveryManager; + private final LockerManager lockerManager; + private final PluginConfig config; + + public AdminParcelService(ParcelService parcelService, ParcelContentManager parcelContentManager, + DeliveryManager deliveryManager, LockerManager lockerManager, PluginConfig config) { + this.parcelService = parcelService; + this.parcelContentManager = parcelContentManager; + this.deliveryManager = deliveryManager; + this.lockerManager = lockerManager; + this.config = config; + } + + public static int capacity(ParcelSize size) { + return switch (size) { + case SMALL -> 9; + case MEDIUM -> 18; + case LARGE -> 27; + }; + } + + /** Pure delta-shift helper, clamped to never be before {@code now}. */ + public static Instant shiftedDeliveryTimestamp(Instant oldTimestamp, boolean oldPriority, boolean newPriority, + Duration normalDuration, Duration priorityDuration, Instant now) { + Duration oldDuration = oldPriority ? priorityDuration : normalDuration; + Duration newDuration = newPriority ? priorityDuration : normalDuration; + Instant shifted = oldTimestamp.plus(newDuration).minus(oldDuration); + return shifted.isBefore(now) ? now : shifted; + } + + private CompletableFuture persist(Parcel updated) { + return this.parcelService.update(updated).thenApply(ignored -> EditResult.ok()); + } + + public CompletableFuture changeName(Parcel parcel, String name) { + return this.persist(withName(parcel, name)); + } + + public CompletableFuture changeDescription(Parcel parcel, String description) { + return this.persist(withDescription(parcel, description)); + } + + public CompletableFuture changeStatus(Parcel parcel, ParcelStatus status) { + return this.persist(withStatus(parcel, status)); + } + + public CompletableFuture changeReceiver(Parcel parcel, UUID receiver) { + return this.persist(withReceiver(parcel, receiver)); + } + + public CompletableFuture changeSize(Parcel parcel, ParcelSize newSize) { + return this.parcelContentManager.get(parcel.uuid()).thenCompose(optional -> { + int itemCount = optional.map(content -> content.items().size()).orElse(0); + if (itemCount > capacity(newSize)) { + return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.SIZE_TOO_SMALL)); + } + return this.persist(withSize(parcel, newSize)); + }); + } + + public CompletableFuture changeDestination(Parcel parcel, UUID destinationLocker) { + return this.lockerManager.isLockerFull(destinationLocker).thenCompose(full -> { + if (Boolean.TRUE.equals(full)) { + return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.DESTINATION_FULL)); + } + return this.persist(withDestination(parcel, destinationLocker)); + }); + } + + public CompletableFuture changePriority(Parcel parcel, boolean newPriority) { + Parcel updated = withPriority(parcel, newPriority); + return this.parcelService.update(updated).thenCompose(ignored -> { + if (parcel.status() != ParcelStatus.SENT || newPriority == parcel.priority()) { + return CompletableFuture.completedFuture(EditResult.ok()); + } + return this.deliveryManager.get(parcel.uuid()).thenCompose(optionalDelivery -> { + if (optionalDelivery.isEmpty()) { + return CompletableFuture.completedFuture(EditResult.ok()); + } + Instant shifted = shiftedDeliveryTimestamp( + optionalDelivery.get().deliveryTimestamp(), + parcel.priority(), newPriority, + this.config.settings.parcelSendDuration, + this.config.settings.priorityParcelSendDuration, + Instant.now()); + return this.deliveryManager.update(parcel.uuid(), shifted) + .thenApply(ignoredDelivery -> EditResult.ok()); + }); + }); + } + + private static Parcel withName(Parcel p, String name) { + return new Parcel(p.uuid(), p.sender(), name, p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withDescription(Parcel p, String description) { + return new Parcel(p.uuid(), p.sender(), p.name(), description, p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withPriority(Parcel p, boolean priority) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), priority, p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withSize(Parcel p, ParcelSize size) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), size, p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withStatus(Parcel p, ParcelStatus status) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), status); + } + + private static Parcel withReceiver(Parcel p, UUID receiver) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), receiver, p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withDestination(Parcel p, UUID destinationLocker) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), destinationLocker, p.status()); + } +} +``` + +Note: `changePriority` uses `DeliveryManager#update` (added in Step 0) — NOT `create`, which throws on an existing delivery. The reschedule of the actual `BukkitRunnable` is handled by Task 9's re-fetch refactor — `changePriority` only needs to move the stored timestamp. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `./gradlew test --tests "com.eternalcode.parcellockers.parcel.service.AdminParcelServiceTest"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/delivery/ src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java +git commit -m "feat: add AdminParcelService + delivery update path with size/priority/destination invariants" +``` + +--- + +### Task 9: `ParcelSendTask` re-fetch refactor (Option A) + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java` (call site — pass `this.scheduler`) +- Modify: `src/main/java/com/eternalcode/parcellockers/ParcelLockers.java` (startup reschedule loop — pass `scheduler`) +- Test: `src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java` + +**Interfaces:** +- The decision is extracted into a **pure, testable method**: `static Decision decide(Optional currentParcel, Optional currentDelivery, Instant now)` returning `enum Decision { DELIVER, RESCHEDULE, ABORT }`. +- Constructor gains a `Scheduler` for self-rescheduling: `ParcelSendTask(Parcel, ParcelService, DeliveryManager, Scheduler)`. Both existing call sites (`ParcelDispatchService`, `ParcelLockers` startup loop) already have a `scheduler` in scope and must pass it. + +- [ ] **Step 1: Write the failing unit test for the decision logic** + +Create `ParcelSendTaskTest.java`: + +```java +package com.eternalcode.parcellockers.parcel.task; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.task.ParcelSendTask.Decision; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ParcelSendTaskTest { + + private static Parcel parcel(ParcelStatus status) { + UUID id = UUID.randomUUID(); + return new Parcel(id, UUID.randomUUID(), "n", "d", false, UUID.randomUUID(), + ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), status); + } + + @Test + void abortsWhenParcelMissing() { + assertEquals(Decision.ABORT, ParcelSendTask.decide(Optional.empty(), Optional.empty(), Instant.now())); + } + + @Test + void abortsWhenAlreadyDelivered() { + Parcel delivered = parcel(ParcelStatus.DELIVERED); + assertEquals(Decision.ABORT, ParcelSendTask.decide(Optional.of(delivered), Optional.empty(), Instant.now())); + } + + @Test + void reschedulesWhenDeliveryMovedToFuture() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Parcel sent = parcel(ParcelStatus.SENT); + Delivery future = new Delivery(sent.uuid(), now.plus(Duration.ofMinutes(2))); + assertEquals(Decision.RESCHEDULE, ParcelSendTask.decide(Optional.of(sent), Optional.of(future), now)); + } + + @Test + void deliversWhenDueAndStillSent() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Parcel sent = parcel(ParcelStatus.SENT); + Delivery due = new Delivery(sent.uuid(), now.minus(Duration.ofSeconds(1))); + assertEquals(Decision.DELIVER, ParcelSendTask.decide(Optional.of(sent), Optional.of(due), now)); + } + + @Test + void deliversWhenSentWithNoDeliveryRow() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Parcel sent = parcel(ParcelStatus.SENT); + assertEquals(Decision.DELIVER, ParcelSendTask.decide(Optional.of(sent), Optional.empty(), now)); + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `./gradlew test --tests "com.eternalcode.parcellockers.parcel.task.ParcelSendTaskTest"` +Expected: compile failure — `ParcelSendTask.Decision` / `decide` do not exist. + +- [ ] **Step 3: Refactor `ParcelSendTask`** + +Replace the body of `ParcelSendTask.java` with: + +```java +package com.eternalcode.parcellockers.parcel.task; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.event.ParcelDeliverEvent; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitRunnable; + +public class ParcelSendTask extends BukkitRunnable { + + private static final Logger LOGGER = Logger.getLogger(ParcelSendTask.class.getName()); + + public enum Decision { DELIVER, RESCHEDULE, ABORT } + + private final UUID parcelId; + private final ParcelService parcelService; + private final DeliveryManager deliveryManager; + private final Scheduler scheduler; + + public ParcelSendTask(Parcel parcel, ParcelService parcelService, DeliveryManager deliveryManager, Scheduler scheduler) { + this.parcelId = parcel.uuid(); + this.parcelService = parcelService; + this.deliveryManager = deliveryManager; + this.scheduler = scheduler; + } + + /** Pure decision: what to do given the latest parcel + delivery state at fire time. */ + public static Decision decide(Optional currentParcel, Optional currentDelivery, Instant now) { + if (currentParcel.isEmpty() || currentParcel.get().status() == ParcelStatus.DELIVERED) { + return Decision.ABORT; + } + if (currentDelivery.isPresent() && currentDelivery.get().deliveryTimestamp().isAfter(now)) { + return Decision.RESCHEDULE; + } + return Decision.DELIVER; + } + + @Override + public void run() { + this.parcelService.get(this.parcelId).thenCompose(optionalParcel -> + this.deliveryManager.get(this.parcelId).thenAccept(optionalDelivery -> { + Instant now = Instant.now(); + switch (decide(optionalParcel, optionalDelivery, now)) { + case ABORT -> + // Parcel gone or already delivered: clean up any stray delivery row. + optionalDelivery.ifPresent(delivery -> this.deliveryManager.delete(this.parcelId)); + case RESCHEDULE -> { + Duration remaining = Duration.between(now, optionalDelivery.get().deliveryTimestamp()); + // Reschedule a fresh task; this instance ends after this run. + this.scheduler.runLaterAsync( + new ParcelSendTask(optionalParcel.get(), this.parcelService, this.deliveryManager, this.scheduler), + remaining.isNegative() ? Duration.ZERO : remaining); + } + case DELIVER -> this.deliver(optionalParcel.get()); + } + })).exceptionally(throwable -> { + LOGGER.severe("ParcelSendTask failed for " + this.parcelId + ": " + throwable.getMessage()); + return null; + }); + } + + private void deliver(Parcel current) { + Parcel delivered = new Parcel(current.uuid(), current.sender(), current.name(), current.description(), + current.priority(), current.receiver(), current.size(), current.entryLocker(), + current.destinationLocker(), ParcelStatus.DELIVERED); + + ParcelDeliverEvent event = new ParcelDeliverEvent(delivered); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + LOGGER.info("ParcelDeliverEvent was cancelled for parcel " + delivered.uuid()); + return; + } + + this.parcelService.update(delivered) + .thenCompose(ignored -> this.deliveryManager.delete(delivered.uuid())) + .exceptionally(throwable -> { + LOGGER.severe("Failed to deliver parcel " + delivered.uuid() + + " (delivery left for retry): " + throwable.getMessage()); + return null; + }); + } +} +``` + +Notes for the implementer: +- Reschedule uses the existing `Scheduler#runLaterAsync(Runnable, Duration)` (same abstraction the dispatch and startup loop already use) — no plugin-name lookup or raw Bukkit scheduling. +- `DeliveryManager#get(UUID)` and `#delete(UUID)` already exist and are used here. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./gradlew test --tests "com.eternalcode.parcellockers.parcel.task.ParcelSendTaskTest"` +Expected: PASS. + +- [ ] **Step 5: Update the two call sites to pass `scheduler`** + +In `ParcelDispatchService.java`, the `ParcelSendTask` construction (~line 103) becomes: + +```java + ParcelSendTask task = new ParcelSendTask( + parcel, + this.parcelService, + this.deliveryManager, + this.scheduler + ); +``` + +In `ParcelLockers.java`, the startup reschedule loop (~line 225) becomes: + +```java + scheduler.runLaterAsync( + new ParcelSendTask(parcel, parcelService, deliveryManager, scheduler), + Duration.ofMillis(delay)); +``` + +- [ ] **Step 6: Verify the whole project compiles** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java src/main/java/com/eternalcode/parcellockers/ParcelLockers.java src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java +git commit -m "refactor: ParcelSendTask re-fetches state at fire time (safe admin edits)" +``` + +--- + +### Task 10: `AdminParcelContentGui` (content editor) + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java` (+`updateParcelContent`) + +**Interfaces:** +- Consumes: `GuiManager#getParcelContent(UUID)`, new `GuiManager#updateParcelContent(UUID, List)`. +- Produces: `new AdminParcelContentGui(scheduler, guiSettings, miniMessage, guiManager, noticeService, parcel, onClose).show(player)` where `onClose` is a `Consumer` reopening the edit GUI. + +- [ ] **Step 1: Add `updateParcelContent` to `GuiManager`** + +```java + public CompletableFuture updateParcelContent(UUID parcelId, List items) { + return this.parcelContentManager.update(parcelId, items); + } +``` + +- [ ] **Step 2: Create `AdminParcelContentGui`** (mirrors `ItemStorageGui`, prefilled, rows by size) + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import com.eternalcode.commons.bukkit.ItemUtil; +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.util.MaterialUtil; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.StorageGui; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.IntStream; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public class AdminParcelContentGui { + + private final Scheduler scheduler; + private final GuiSettings guiSettings; + private final MiniMessage miniMessage; + private final GuiManager guiManager; + private final NoticeService noticeService; + private final Parcel parcel; + private final Consumer onClose; + + public AdminParcelContentGui(Scheduler scheduler, GuiSettings guiSettings, MiniMessage miniMessage, + GuiManager guiManager, NoticeService noticeService, Parcel parcel, Consumer onClose) { + this.scheduler = scheduler; + this.guiSettings = guiSettings; + this.miniMessage = miniMessage; + this.guiManager = guiManager; + this.noticeService = noticeService; + this.parcel = parcel; + this.onClose = onClose; + } + + public void show(Player player) { + int rows = switch (this.parcel.size()) { + case SMALL -> 2; + case MEDIUM -> 3; + case LARGE -> 4; + }; + + StorageGui gui = dev.triumphteam.gui.guis.Gui.storage() + .title(this.miniMessage.deserialize(this.guiSettings.adminParcelContentGuiTitle)) + .rows(rows) + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(event -> event.setCancelled(true)); + IntStream.rangeClosed(1, 9).forEach(i -> gui.setItem(gui.getRows(), i, background)); + gui.setItem(gui.getRows(), 5, this.guiSettings.confirmItemsItem.toGuiItem(event -> { + event.setCancelled(true); + gui.close(player); + })); + + gui.setCloseGuiAction(event -> { + ItemStack[] contents = gui.getInventory().getContents(); + List items = new ArrayList<>(); + List illegalItems = new ArrayList<>(); + for (int i = 0; i < contents.length - 9; i++) { + ItemStack item = contents[i]; + if (item == null) { + continue; + } + if (this.guiSettings.illegalItems.contains(item.getType())) { + illegalItems.add(item); + } else { + items.add(item); + } + } + for (ItemStack illegalItem : illegalItems) { + ItemUtil.giveItem(player, illegalItem); + this.noticeService.create() + .notice(messages -> messages.parcel.illegalItem) + .placeholder("{ITEMS}", MaterialUtil.format(illegalItem.getType())) + .player(player.getUniqueId()) + .send(); + } + this.guiManager.updateParcelContent(this.parcel.uuid(), items) + .thenAccept(saved -> { + this.noticeService.create().notice(m -> m.admin.contentsUpdated).player(player.getUniqueId()).send(); + this.scheduler.run(() -> this.onClose.accept(player)); + }) + .exceptionally(throwable -> { + this.scheduler.run(() -> items.forEach(item -> ItemUtil.giveItem(player, item))); + return FutureHandler.handleException(throwable); + }); + }); + + this.guiManager.getParcelContent(this.parcel.uuid()).thenAccept(optional -> { + optional.ifPresent(content -> gui.addItem(content.items().toArray(new ItemStack[0]))); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } +} +``` + +- [ ] **Step 3: Verify compilation** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +git commit -m "feat: add admin parcel content editor GUI" +``` + +--- + +### Task 11: `AdminParcelListGui` + pickers + `AdminParcelEditGui` + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelListGui.java` +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminReceiverPickerGui.java` +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminDestinationPickerGui.java` +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java` + +**Interfaces:** +- Consumes: `GuiManager#getAllParcels`, `#getUsers`, `#getLockerPage`, `AdminParcelService`, `AdminParcelContentGui`, `ConfirmationDialogFactory`. +- Produces: `new AdminParcelListGui(...).show(player)` and `new AdminParcelEditGui(...).show(player)` (constructor params enumerated in the code below). + +- [ ] **Step 1: Create `AdminParcelListGui`** (mirrors `AdminLockerListGui`, uses `getAllParcels`) + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.service.AdminParcelService; +import com.eternalcode.parcellockers.shared.Page; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminParcelListGui implements GuiView { + + private static final Page FIRST_PAGE = new Page(0, 28); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final MessageConfig messageConfig; + private final NoticeService noticeService; + private final GuiManager guiManager; + private final AdminParcelService adminParcelService; + private final GuiView parent; + + public AdminParcelListGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + MessageConfig messageConfig, NoticeService noticeService, GuiManager guiManager, + AdminParcelService adminParcelService, GuiView parent) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.messageConfig = messageConfig; + this.noticeService = noticeService; + this.guiManager = guiManager; + this.adminParcelService = adminParcelService; + this.parent = parent; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminParcelListGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + this.guiManager.getAllParcels(page).thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (Parcel parcel : result.items()) { + gui.addItem(this.createRow(player, parcel)); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } + + private GuiItem createRow(Player player, Parcel parcel) { + ConfigItem row = this.guiSettings.adminParcelRowItem.clone(); + return row.name(row.name().replace("{NAME}", parcel.name())) + .lore(row.lore().stream().map(line -> line + .replace("{STATUS}", parcel.status().name()) + .replace("{SIZE}", parcel.size().name()) + .replace("{PRIORITY}", parcel.priority() ? "Yes" : "No") + .replace("{UUID}", parcel.uuid().toString())).toList()) + .toGuiItem(event -> new AdminParcelEditGui(this.scheduler, this.miniMessage, this.guiSettings, + this.messageConfig, this.noticeService, this.guiManager, this.adminParcelService, this, parcel).show(player)); + } +} +``` + +- [ ] **Step 2: Create `AdminReceiverPickerGui`** (paginated users; on pick → callback) + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.user.User; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.util.function.BiConsumer; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminReceiverPickerGui implements GuiView { + + private static final Page FIRST_PAGE = new Page(0, 28); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final GuiManager guiManager; + private final GuiView parent; + private final BiConsumer onPick; + + public AdminReceiverPickerGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + GuiManager guiManager, GuiView parent, BiConsumer onPick) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.guiManager = guiManager; + this.parent = parent; + this.onPick = onPick; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminUserListGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + this.guiManager.getUsers(page).thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (User user : result.items()) { + ConfigItem row = this.guiSettings.adminUserRowItem.clone(); + gui.addItem(row.name(row.name().replace("{NAME}", user.name())) + .lore(row.lore().stream().map(line -> line.replace("{UUID}", user.uuid().toString())).toList()) + .toGuiItem(event -> this.onPick.accept(player, user))); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } +} +``` + +- [ ] **Step 3: Create `AdminDestinationPickerGui`** (paginated lockers; identical structure to Step 2 but iterating `getLockerPage` and calling `onPick` with a `Locker`) + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.shared.Page; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.util.function.BiConsumer; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminDestinationPickerGui implements GuiView { + + private static final Page FIRST_PAGE = new Page(0, 28); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final GuiManager guiManager; + private final GuiView parent; + private final BiConsumer onPick; + + public AdminDestinationPickerGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + GuiManager guiManager, GuiView parent, BiConsumer onPick) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.guiManager = guiManager; + this.parent = parent; + this.onPick = onPick; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminLockerListGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + this.guiManager.getLockerPage(page).thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (Locker locker : result.items()) { + ConfigItem row = this.guiSettings.adminLockerRowItem.clone(); + gui.addItem(row.name(row.name().replace("{NAME}", locker.name())) + .lore(row.lore().stream().map(line -> line + .replace("{POSITION}", locker.position().toString()) + .replace("{UUID}", locker.uuid().toString())).toList()) + .toGuiItem(event -> this.onPick.accept(player, locker))); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } +} +``` + +- [ ] **Step 4: Create `AdminParcelEditGui`** + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.service.AdminParcelService; +import com.eternalcode.parcellockers.parcel.service.EditResult; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.dialog.DialogResponseView; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.input.DialogInput; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +@SuppressWarnings("UnstableApiUsage") +public class AdminParcelEditGui implements GuiView { + + private static final int NAME_SLOT = 10; + private static final int DESCRIPTION_SLOT = 11; + private static final int PRIORITY_SLOT = 12; + private static final int SIZE_SLOT = 13; + private static final int STATUS_SLOT = 14; + private static final int RECEIVER_SLOT = 15; + private static final int DESTINATION_SLOT = 16; + private static final int CONTENTS_SLOT = 30; + private static final int DELETE_SLOT = 32; + private static final int CLOSE_SLOT = 49; + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final MessageConfig messageConfig; + private final NoticeService noticeService; + private final GuiManager guiManager; + private final AdminParcelService adminParcelService; + private final GuiView parent; + private final Parcel parcel; + private final ConfirmationDialogFactory confirmationDialogFactory; + + public AdminParcelEditGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + MessageConfig messageConfig, NoticeService noticeService, GuiManager guiManager, + AdminParcelService adminParcelService, GuiView parent, Parcel parcel) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.messageConfig = messageConfig; + this.noticeService = noticeService; + this.guiManager = guiManager; + this.adminParcelService = adminParcelService; + this.parent = parent; + this.parcel = parcel; + this.confirmationDialogFactory = new ConfirmationDialogFactory(miniMessage); + } + + @Override + public void show(Player player) { + Gui gui = Gui.gui() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminParcelEditGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + for (int i = 0; i < gui.getRows() * 9; i++) { + gui.setItem(i, background); + } + + gui.setItem(NAME_SLOT, this.button(this.guiSettings.adminEditNameButton, "{NAME}", this.parcel.name(), + event -> this.openTextDialog(player, "Enter parcel name:", "Name", name -> { + if (name == null || name.isBlank()) { + this.scheduler.run(() -> this.show(player)); + return; + } + this.apply(player, this.adminParcelService.changeName(this.parcel, name), name, null, null); + }))); + + gui.setItem(DESCRIPTION_SLOT, this.button(this.guiSettings.adminEditDescriptionButton, "{DESCRIPTION}", this.parcel.description(), + event -> this.openTextDialog(player, "Enter description:", "Description", description -> + this.apply(player, this.adminParcelService.changeDescription(this.parcel, description), null, description, null)))); + + gui.setItem(PRIORITY_SLOT, this.button(this.guiSettings.adminEditPriorityButton, "{PRIORITY}", this.parcel.priority() ? "Yes" : "No", + event -> this.applyPriority(player, !this.parcel.priority()))); + + gui.setItem(SIZE_SLOT, this.button(this.guiSettings.adminEditSizeButton, "{SIZE}", this.parcel.size().name(), + event -> this.applySize(player, nextSize(this.parcel.size())))); + + gui.setItem(STATUS_SLOT, this.button(this.guiSettings.adminEditStatusButton, "{STATUS}", this.parcel.status().name(), + event -> this.applyStatus(player, nextStatus(this.parcel.status())))); + + gui.setItem(RECEIVER_SLOT, this.button(this.guiSettings.adminEditReceiverButton, "{RECEIVER}", this.parcel.receiver().toString(), + event -> new AdminReceiverPickerGui(this.scheduler, this.miniMessage, this.guiSettings, this.guiManager, this, + (p, user) -> this.apply(p, this.adminParcelService.changeReceiver(this.parcel, user.uuid()), null, null, null)).show(player))); + + gui.setItem(DESTINATION_SLOT, this.button(this.guiSettings.adminEditDestinationButton, "{DESTINATION}", this.parcel.destinationLocker().toString(), + event -> new AdminDestinationPickerGui(this.scheduler, this.miniMessage, this.guiSettings, this.guiManager, this, + (p, locker) -> this.applyDestination(p, locker.uuid())).show(player))); + + gui.setItem(CONTENTS_SLOT, this.guiSettings.adminEditContentsButton.toGuiItem(event -> + new AdminParcelContentGui(this.scheduler, this.guiSettings, this.miniMessage, this.guiManager, + this.noticeService, this.parcel, this::reopenFresh).show(player))); + + gui.setItem(DELETE_SLOT, this.guiSettings.adminDeleteParcelButton.toGuiItem(event -> + player.showDialog(this.confirmationDialogFactory.create( + "Delete parcel '" + this.parcel.name() + "'?", + "Delete", "Cancel", + () -> this.guiManager.deleteParcel(this.parcel).thenRun(() -> { + this.noticeService.create().notice(m -> m.admin.parcelDeleted).player(player.getUniqueId()).send(); + this.scheduler.run(() -> this.parent.show(player)); + }).exceptionally(FutureHandler::handleException), + () -> this.scheduler.run(() -> this.show(player)))))); + + gui.setItem(CLOSE_SLOT, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + gui.setDefaultClickAction(event -> event.setCancelled(true)); + this.scheduler.run(() -> gui.open(player)); + } + + private GuiItem button(ConfigItem template, String placeholder, String value, dev.triumphteam.gui.components.GuiAction action) { + ConfigItem item = template.clone(); + return item.name(item.name().replace(placeholder, value)) + .lore(item.lore().stream().map(line -> line.replace(placeholder, value)).toList()) + .toGuiItem(action); + } + + private void apply(Player player, CompletableFuture future, String newName, String newDescription, ParcelStatus ignored) { + future.thenAccept(result -> { + this.notifyResult(player, result); + this.scheduler.run(() -> this.reopenFresh(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void applyPriority(Player player, boolean priority) { + this.adminParcelService.changePriority(this.parcel, priority).thenAccept(result -> { + this.noticeService.create().notice(m -> m.admin.priorityUpdated).player(player.getUniqueId()).send(); + this.scheduler.run(() -> this.reopenFresh(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void applySize(Player player, ParcelSize size) { + this.adminParcelService.changeSize(this.parcel, size).thenAccept(result -> { + this.notifyResult(player, result); + this.scheduler.run(() -> this.reopenFresh(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void applyStatus(Player player, ParcelStatus status) { + this.adminParcelService.changeStatus(this.parcel, status).thenAccept(result -> { + this.notifyResult(player, result); + this.scheduler.run(() -> this.reopenFresh(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void applyDestination(Player player, java.util.UUID destination) { + this.adminParcelService.changeDestination(this.parcel, destination).thenAccept(result -> { + this.notifyResult(player, result); + this.scheduler.run(() -> this.reopenFresh(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void notifyResult(Player player, EditResult result) { + switch (result.status()) { + case OK -> this.noticeService.create().notice(m -> m.admin.parcelUpdated).player(player.getUniqueId()).send(); + case SIZE_TOO_SMALL -> this.noticeService.create().notice(m -> m.admin.sizeTooSmall).player(player.getUniqueId()).send(); + case DESTINATION_FULL -> this.noticeService.create().notice(m -> m.admin.destinationFull).player(player.getUniqueId()).send(); + } + } + + /** Re-fetches the parcel so the editor reflects the just-applied change, then reopens. */ + private void reopenFresh(Player player) { + this.guiManager.getParcel(this.parcel.uuid()).thenAccept(optional -> { + if (optional.isEmpty()) { + this.scheduler.run(() -> this.parent.show(player)); + return; + } + this.scheduler.run(() -> new AdminParcelEditGui(this.scheduler, this.miniMessage, this.guiSettings, + this.messageConfig, this.noticeService, this.guiManager, this.adminParcelService, this.parent, optional.get()).show(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void openTextDialog(Player player, String title, String placeholder, java.util.function.Consumer onConfirm) { + Dialog dialog = Dialog.create(builder -> builder.empty() + .base(DialogBase.builder(this.miniMessage.deserialize(title)) + .inputs(List.of(DialogInput.text("value", this.miniMessage.deserialize(placeholder)).build())) + .build()) + .type(DialogType.confirmation( + ActionButton.create(this.miniMessage.deserialize("Confirm"), null, 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> onConfirm.accept(view.getText("value")), + ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build())), + ActionButton.create(this.miniMessage.deserialize("Cancel"), null, 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> this.scheduler.run(() -> this.show(player)), + ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build()))))); + player.showDialog(dialog); + } + + private static ParcelSize nextSize(ParcelSize size) { + return switch (size) { + case SMALL -> ParcelSize.MEDIUM; + case MEDIUM -> ParcelSize.LARGE; + case LARGE -> ParcelSize.SMALL; + }; + } + + private static ParcelStatus nextStatus(ParcelStatus status) { + return status == ParcelStatus.SENT ? ParcelStatus.DELIVERED : ParcelStatus.SENT; + } +} +``` + +- [ ] **Step 5: Add `getParcel` and `deleteParcel` passthroughs to `GuiManager`** + +```java + public CompletableFuture> getParcel(UUID uuid) { + return this.parcelService.get(uuid); + } + + public CompletableFuture deleteParcel(Parcel parcel) { + return this.parcelService.delete(parcel); + } +``` + +- [ ] **Step 6: Verify compilation** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcel*.java src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/Admin*PickerGui.java src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +git commit -m "feat: add admin parcel list, pickers, and per-field edit GUI" +``` + +--- + +### Task 12: `AdminGui` root + bulk actions + command entry + wiring + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/ParcelLockers.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java` (+`deleteAllParcels`, `deleteAllLockers` passthroughs taking the `NoticeService`/sender) + +**Interfaces:** +- Consumes everything above. `AdminGui` needs: scheduler, miniMessage, guiSettings, messageConfig, noticeService, guiManager, adminParcelService. +- Produces: `AdminGui#show(Player)`. + +- [ ] **Step 1: Add bulk-delete passthroughs to `GuiManager`** + +The existing `ParcelService#deleteAll(sender, noticeService)` and `LockerManager#deleteAll(sender, noticeService)` already send their own notices. Expose them: + +```java + public CompletableFuture deleteAllParcels(org.bukkit.command.CommandSender sender, com.eternalcode.parcellockers.notification.NoticeService noticeService) { + return this.parcelService.deleteAll(sender, noticeService); + } + + public CompletableFuture deleteAllLockers(org.bukkit.command.CommandSender sender, com.eternalcode.parcellockers.notification.NoticeService noticeService) { + return this.lockerManager.deleteAll(sender, noticeService); + } +``` + +- [ ] **Step 2: Create `AdminGui`** + +```java +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.service.AdminParcelService; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +@SuppressWarnings("UnstableApiUsage") +public class AdminGui implements GuiView { + + private static final int PARCELS_SLOT = 20; + private static final int LOCKERS_SLOT = 22; + private static final int USERS_SLOT = 24; + private static final int DELETE_PARCELS_SLOT = 38; + private static final int DELETE_LOCKERS_SLOT = 42; + private static final int CLOSE_SLOT = 49; + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final MessageConfig messageConfig; + private final NoticeService noticeService; + private final GuiManager guiManager; + private final AdminParcelService adminParcelService; + private final ConfirmationDialogFactory confirmationDialogFactory; + + public AdminGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, MessageConfig messageConfig, + NoticeService noticeService, GuiManager guiManager, AdminParcelService adminParcelService) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.messageConfig = messageConfig; + this.noticeService = noticeService; + this.guiManager = guiManager; + this.adminParcelService = adminParcelService; + this.confirmationDialogFactory = new ConfirmationDialogFactory(miniMessage); + } + + @Override + public void show(Player player) { + Gui gui = Gui.gui() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int i = 0; i < gui.getRows() * 9; i++) { + gui.setItem(i, background); + } + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + + gui.setItem(PARCELS_SLOT, this.guiSettings.adminParcelsButton.toGuiItem(event -> + new AdminParcelListGui(this.scheduler, this.miniMessage, this.guiSettings, this.messageConfig, + this.noticeService, this.guiManager, this.adminParcelService, this).show(player))); + + gui.setItem(LOCKERS_SLOT, this.guiSettings.adminLockersButton.toGuiItem(event -> + new AdminLockerListGui(this.scheduler, this.miniMessage, this.guiSettings, this.messageConfig, + this.noticeService, this.guiManager, this).show(player))); + + gui.setItem(USERS_SLOT, this.guiSettings.adminUsersButton.toGuiItem(event -> + new AdminUserListGui(this.scheduler, this.miniMessage, this.guiSettings, this.guiManager, this).show(player))); + + gui.setItem(DELETE_PARCELS_SLOT, this.guiSettings.adminDeleteAllParcelsButton.toGuiItem(event -> + player.showDialog(this.confirmationDialogFactory.create( + "Delete ALL parcels? This cannot be undone.", + "Delete all", "Cancel", + () -> this.guiManager.deleteAllParcels(player, this.noticeService) + .thenRun(() -> this.scheduler.run(() -> this.show(player))) + .exceptionally(FutureHandler::handleException), + () -> this.scheduler.run(() -> this.show(player)))))); + + gui.setItem(DELETE_LOCKERS_SLOT, this.guiSettings.adminDeleteAllLockersButton.toGuiItem(event -> + player.showDialog(this.confirmationDialogFactory.create( + "Delete ALL lockers? This cannot be undone.", + "Delete all", "Cancel", + () -> this.guiManager.deleteAllLockers(player, this.noticeService) + .thenRun(() -> this.scheduler.run(() -> this.show(player))) + .exceptionally(FutureHandler::handleException), + () -> this.scheduler.run(() -> this.show(player)))))); + + gui.setItem(CLOSE_SLOT, this.guiSettings.closeItem.toGuiItem(event -> gui.close(player))); + gui.setDefaultClickAction(event -> event.setCancelled(true)); + this.scheduler.run(() -> gui.open(player)); + } +} +``` + +- [ ] **Step 3: Add the `admin` subcommand** + +In `ParcelLockersCommand.java`, add an `AdminGui` field + constructor param, and: + +```java + @Execute(name = "admin") + void admin(@Sender Player player) { + this.adminGui.show(player); + } +``` + +Update the constructor to accept and store `AdminGui adminGui` (add `import com.eternalcode.parcellockers.gui.implementation.admin.AdminGui;` and `import org.bukkit.entity.Player;` — Player is already imported). + +- [ ] **Step 4: Wire in `ParcelLockers#onEnable`** + +After `lockerGUI` is constructed (~line 178) and before the `liteCommandsBuilder`, add: + +```java + AdminParcelService adminParcelService = new AdminParcelService( + parcelService, parcelContentManager, deliveryManager, lockerManager, config); + + AdminGui adminGUI = new AdminGui( + scheduler, miniMessage, config.guiSettings, messageConfig, + noticeService, guiManager, adminParcelService); +``` + +Then change the command registration from `new ParcelLockersCommand(configService, config, noticeService)` to: + +```java + new ParcelLockersCommand(configService, config, noticeService, adminGUI), +``` + +Add imports: +```java +import com.eternalcode.parcellockers.gui.implementation.admin.AdminGui; +import com.eternalcode.parcellockers.parcel.service.AdminParcelService; +``` + +- [ ] **Step 5: Verify compilation + full test run** + +Run: `./gradlew compileJava && ./gradlew test` +Expected: BUILD SUCCESSFUL; all unit tests pass (DB integration tests SKIPPED without Docker). + +- [ ] **Step 6: Build the plugin jar to confirm shadowJar succeeds** + +Run: `./gradlew shadowJar` +Expected: BUILD SUCCESSFUL; jar in `build/libs/`. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java src/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java src/main/java/com/eternalcode/parcellockers/ParcelLockers.java src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +git commit -m "feat: add admin GUI root, bulk actions, and /parcellockers admin entry" +``` + +--- + +## Manual Verification (after Task 12) + +Run a local server (`./gradlew runServer`), op yourself, and verify: +1. `/parcellockers admin` opens the root menu (requires `parcellockers.admin`). +2. Parcels → pick a parcel → edit each field; confirm size-shrink with too many items is rejected with `sizeTooSmall`; toggling priority on a SENT parcel updates the delivery time shown in the player parcel list. +3. Edit contents → add/remove items → reopen the editor and confirm persistence. +4. Lockers → rename (dialog), teleport, delete. +5. Users → inspect → received parcels list renders. +6. Bulk delete buttons prompt a confirmation dialog and only delete on confirm. + +--- + +## Self-Review Notes (author) + +- **Spec coverage:** entry point (T12), parcel fields incl. contents (T8/T10/T11), size-fit (T8), priority delta-shift+clamp (T8), in-transit safety (T9), locker rename/teleport/delete (T1/T6), users (T7), bulk actions (T12), content update path (T2), delivery update path (T8 Step 0), all-parcels query (T3), config/messages (T4), tests (T1/T2/T3/T8/T9). All covered. +- **Verified APIs:** `DeliveryManager#get(UUID)`/`#delete(UUID)` exist; `#create` THROWS on existing delivery (hence the new `#update` in T8 Step 0). `DeliveryRepository#save` and `ParcelContentRepository#save` are both insert-if-absent (hence the upsert `update` methods). `NoticeService.create().notice(m -> ...).player(uuid).send()` and `.viewer(sender)` chains match existing usage in `SendingGui`/`DeliveryManager`. `AbstractRepositoryOrmLite#upsert` provides `createOrUpdate`. No unverified signatures remain. From d04f4064b69426402cecdcfdebec2dc345ca3b28 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:33:23 +0200 Subject: [PATCH 04/21] feat: add locker rename (repository update + manager rename) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../parcellockers/locker/LockerManager.java | 18 +++++ .../locker/repository/LockerRepository.java | 2 + .../repository/LockerRepositoryOrmLite.java | 5 ++ .../database/LockerRenameIntegrationTest.java | 69 +++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java b/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java index c04e1244..05e5f814 100644 --- a/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java +++ b/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java @@ -208,6 +208,24 @@ public CompletableFuture deleteAll(CommandSender sender, NoticeService not }); } + public CompletableFuture rename(UUID uniqueId, String newName) { + return this.get(uniqueId).thenCompose(optional -> { + if (optional.isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Locker not found: " + uniqueId)); + } + + Locker existing = optional.get(); + Locker renamed = new Locker(existing.uuid(), newName, existing.position()); + + return this.lockerRepository.update(renamed).thenApply(saved -> { + this.lockersByUUID.put(saved.uuid(), saved); + this.lockersByPosition.put(saved.position(), saved); + return saved; + }); + }); + } + public CompletableFuture isLockerFull(UUID uniqueId) { return this.parcelRepository.countParcelsByDestinationLocker(uniqueId) .thenApply(count -> count >= this.config.settings.maxParcelsPerLocker); diff --git a/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepository.java b/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepository.java index 223b7a6c..8f330f9b 100644 --- a/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepository.java +++ b/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepository.java @@ -12,6 +12,8 @@ public interface LockerRepository { CompletableFuture save(Locker locker); + CompletableFuture update(Locker locker); + CompletableFuture> find(UUID uuid); CompletableFuture> find(Position position); diff --git a/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java index dae09470..a782e837 100644 --- a/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java @@ -24,6 +24,11 @@ public CompletableFuture save(Locker locker) { return this.insertIfAbsent(LockerTable.class, LockerTable.from(locker)).thenApply(LockerTable::toLocker); } + @Override + public CompletableFuture update(Locker locker) { + return this.upsert(LockerTable.class, LockerTable.from(locker)).thenApply(status -> locker); + } + @Override public CompletableFuture> find(UUID uuid) { return this.selectSafe(LockerTable.class, uuid).thenApply(optional -> optional.map(LockerTable::toLocker)); diff --git a/src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java new file mode 100644 index 00000000..98183f43 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java @@ -0,0 +1,69 @@ +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.locker.repository.LockerRepository; +import com.eternalcode.parcellockers.locker.repository.LockerRepositoryOrmLite; +import com.eternalcode.parcellockers.shared.Position; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.Optional; +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 LockerRenameIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + @Test + void updateOverwritesExistingName() 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; + + LockerRepository repository = new LockerRepositoryOrmLite(databaseManager, new TestScheduler()); + UUID uuid = UUID.randomUUID(); + Position position = new Position(1, 2, 3, "world"); + this.await(repository.save(new Locker(uuid, "Old name", position))); + + this.await(repository.update(new Locker(uuid, "New name", position))); + + Optional reloaded = this.await(repository.find(uuid)); + assertTrue(reloaded.isPresent()); + assertEquals("New name", reloaded.get().name()); + assertEquals(position, reloaded.get().position()); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} From 22760f9981cac8216af845578be52486fbbf559c Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:40:50 +0200 Subject: [PATCH 05/21] feat: add parcel content update/upsert path Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../content/ParcelContentManager.java | 8 +++ .../repository/ParcelContentRepository.java | 2 + .../ParcelContentRepositoryOrmLite.java | 5 ++ .../ParcelContentUpdateIntegrationTest.java | 71 +++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java b/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java index d1c59bfe..69cdc053 100644 --- a/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java +++ b/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java @@ -51,6 +51,14 @@ public ParcelContent create(UUID parcel, List items) { return content; } + public CompletableFuture update(UUID parcel, List items) { + ParcelContent content = new ParcelContent(parcel, items); + return this.contentRepository.update(content).thenApply(ignored -> { + this.cache.put(parcel, content); + return content; + }); + } + public CompletableFuture delete(UUID parcel) { return this.contentRepository.delete(parcel).thenApply(success -> { this.cache.invalidate(parcel); diff --git a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepository.java b/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepository.java index fdb59c2f..99192d30 100644 --- a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepository.java +++ b/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepository.java @@ -9,6 +9,8 @@ public interface ParcelContentRepository { CompletableFuture save(ParcelContent parcelContent); + CompletableFuture update(ParcelContent parcelContent); + CompletableFuture> find(UUID uniqueId); CompletableFuture delete(UUID uniqueId); diff --git a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java index b1fd9df3..2b37611e 100644 --- a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java @@ -20,6 +20,11 @@ public CompletableFuture save(ParcelContent parcelContent) { return this.insertIfAbsent(ParcelContentTable.class, ParcelContentTable.from(parcelContent)).thenApply(dao -> null); } + @Override + public CompletableFuture update(ParcelContent parcelContent) { + return this.upsert(ParcelContentTable.class, ParcelContentTable.from(parcelContent)).thenApply(status -> null); + } + @Override public CompletableFuture delete(UUID uniqueId) { return this.deleteById(ParcelContentTable.class, uniqueId).thenApply(i -> i > 0); diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java new file mode 100644 index 00000000..5de5aa24 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java @@ -0,0 +1,71 @@ +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.content.repository.ParcelContentRepository; +import com.eternalcode.parcellockers.content.repository.ParcelContentRepositoryOrmLite; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +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 ParcelContentUpdateIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + @Test + void updateOverwritesExistingContent() 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; + + ParcelContentRepository repository = new ParcelContentRepositoryOrmLite(databaseManager, new TestScheduler()); + UUID parcel = UUID.randomUUID(); + this.await(repository.save(new ParcelContent(parcel, List.of(new ItemStack(Material.STONE, 1))))); + + this.await(repository.update(new ParcelContent(parcel, List.of(new ItemStack(Material.DIAMOND, 5))))); + + Optional reloaded = this.await(repository.find(parcel)); + assertTrue(reloaded.isPresent()); + assertEquals(1, reloaded.get().items().size()); + assertEquals(Material.DIAMOND, reloaded.get().items().getFirst().getType()); + assertEquals(5, reloaded.get().items().getFirst().getAmount()); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} From 7a5af7908b946468b928d50ba60f2828c317e3bd Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:46:37 +0200 Subject: [PATCH 06/21] feat: add all-parcels pagination (repo findPage + service getAll) - ParcelRepository#findPage(Page): CompletableFuture> - ParcelRepositoryOrmLite#findPage: delegates to queryPage with identity builder - ParcelService#getAll(Page): CompletableFuture> - ParcelServiceImpl#getAll: calls findPage, warms parcelsByUuid cache - GuiManager#getAllParcels(Page): delegates to parcelService.getAll - ParcelFindPageIntegrationTest: TDD test (skipped without Docker) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../parcellockers/gui/GuiManager.java | 4 ++ .../parcel/repository/ParcelRepository.java | 2 + .../repository/ParcelRepositoryOrmLite.java | 6 ++ .../parcel/service/ParcelService.java | 1 + .../parcel/service/ParcelServiceImpl.java | 10 +++ .../ParcelFindPageIntegrationTest.java | 72 +++++++++++++++++++ 6 files changed, 95 insertions(+) create mode 100644 src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index 10415a37..276fdd83 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -66,6 +66,10 @@ public CompletableFuture> getParcelsBySender(UUID sender, Pag return this.parcelService.getBySender(sender, page); } + public CompletableFuture> getAllParcels(Page page) { + return this.parcelService.getAll(page); + } + public CompletableFuture> getUser(UUID userUuid) { return this.userManager.get(userUuid); } 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 b738281a..7f62feca 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java @@ -17,6 +17,8 @@ public interface ParcelRepository { CompletableFuture> findAll(); + CompletableFuture> findPage(Page page); + CompletableFuture> findById(UUID uuid); @TestOnly 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 95fda22b..5bdd549f 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java @@ -117,6 +117,12 @@ public CompletableFuture> findAll() { .toList()); } + @Override + public CompletableFuture> findPage(Page page) { + Objects.requireNonNull(page, "Page cannot be null"); + return this.queryPage(ParcelTable.class, page, builder -> builder, ParcelTable::toParcel); + } + @Override public CompletableFuture deleteAll() { return this.deleteAll(ParcelTable.class); 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 9d58fd50..e733114a 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,7 @@ public interface ParcelService { CompletableFuture> getByReceiver(UUID receiver, 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 d89b88f6..16823ad7 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,16 @@ public CompletableFuture> getByReceiver(UUID receiver, Page p }); } + @Override + public CompletableFuture> getAll(Page page) { + Objects.requireNonNull(page, "Page cannot be null"); + return this.parcelRepository.findPage(page) + .thenApply(result -> { + result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return result; + }); + } + @Override public CompletableFuture delete(UUID uuid) { Objects.requireNonNull(uuid, "UUID cannot be null"); diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java new file mode 100644 index 00000000..4c2bca1d --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java @@ -0,0 +1,72 @@ +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.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 ParcelFindPageIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + @Test + void findPageReturnsAllParcelsAcrossSenders() throws Exception { + 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; + + ParcelRepository repository = new ParcelRepositoryOrmLite(databaseManager, new TestScheduler()); + for (int i = 0; i < 3; i++) { + this.await(repository.save(new Parcel( + UUID.randomUUID(), UUID.randomUUID(), "p" + i, "d", false, + UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.SENT))); + } + + PageResult firstPage = this.await(repository.findPage(new Page(0, 2))); + assertEquals(2, firstPage.items().size()); + assertTrue(firstPage.hasNextPage()); + + PageResult secondPage = this.await(repository.findPage(new Page(1, 2))); + assertEquals(1, secondPage.items().size()); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} From 27df8688807ed89c946f9e5da2b94319a45c1dbe Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:50:26 +0200 Subject: [PATCH 07/21] fix: tighten ParcelFindPageIntegrationTest exception type and assert last-page termination Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../database/ParcelFindPageIntegrationTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java index 4c2bca1d..21ebcda2 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java @@ -1,5 +1,6 @@ package com.eternalcode.parcellockers.database; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -13,6 +14,7 @@ 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; @@ -35,7 +37,7 @@ class ParcelFindPageIntegrationTest extends IntegrationTestSpec { private DatabaseManager databaseManager; @Test - void findPageReturnsAllParcelsAcrossSenders() throws Exception { + void findPageReturnsAllParcelsAcrossSenders() throws SQLException { PluginConfig config = new PluginConfig(); config.settings.databaseType = DatabaseType.MYSQL; config.settings.host = mySQLContainer.getHost(); @@ -61,6 +63,7 @@ void findPageReturnsAllParcelsAcrossSenders() throws Exception { PageResult secondPage = this.await(repository.findPage(new Page(1, 2))); assertEquals(1, secondPage.items().size()); + assertFalse(secondPage.hasNextPage()); } @AfterEach From ef741b2f37c6d7715d54b9c94164758a4a0efa52 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:52:43 +0200 Subject: [PATCH 08/21] feat: add admin GUI config items and admin messages Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../implementation/MessageConfig.java | 11 +++ .../implementation/PluginConfig.java | 74 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java index 3e9e8482..a7f00713 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java @@ -181,6 +181,17 @@ public static class AdminMessages extends OkaeriConfig { public Notice deletedItemStorages = Notice.chat("&4⚠ &cAll ({COUNT}) item storages have been deleted!"); public Notice deletedContents = Notice.chat("&4⚠ &cAll ({COUNT}) parcel contents have been deleted!"); public Notice deletedDeliveries = Notice.chat("&4⚠ &cAll ({COUNT}) deliveries have been deleted!"); + public Notice parcelUpdated = Notice.chat("&2✔ &aParcel updated."); + public Notice parcelDeleted = Notice.chat("&2✔ &aParcel deleted."); + public Notice lockerRenamed = Notice.chat("&2✔ &aLocker renamed."); + public Notice lockerDeleted = Notice.chat("&2✔ &aLocker deleted."); + public Notice teleported = Notice.chat("&2✔ &aTeleported to the locker."); + public Notice teleportWorldMissing = Notice.chat("&4✘ &cThat locker's world is not loaded."); + public Notice sizeTooSmall = Notice.chat("&4✘ &cThe parcel's contents do not fit in that size."); + public Notice destinationFull = Notice.chat("&4✘ &cThat destination locker is full."); + public Notice contentsUpdated = Notice.chat("&2✔ &aParcel contents updated."); + public Notice priorityUpdated = Notice.chat("&2✔ &aPriority updated and delivery time adjusted."); + public Notice noPermission = Notice.chat("&4✘ &cYou do not have permission to do that."); } public static class DiscordMessages extends OkaeriConfig { 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 8d642a50..000930b2 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java @@ -178,6 +178,80 @@ public static class GuiSettings extends OkaeriConfig { .lore(Collections.emptyList()) .type(Material.BLUE_STAINED_GLASS_PANE); + @Comment({ "", "# ----- Admin GUI -----" }) + @Comment("# Title of the admin root menu") + public String adminGuiTitle = "&4Admin panel"; + public String adminParcelListGuiTitle = "&4Admin: parcels"; + public String adminParcelEditGuiTitle = "&4Admin: edit parcel"; + public String adminLockerListGuiTitle = "&4Admin: lockers"; + public String adminLockerEditGuiTitle = "&4Admin: edit locker"; + public String adminUserListGuiTitle = "&4Admin: users"; + public String adminUserInspectGuiTitle = "&4Admin: user parcels"; + public String adminParcelContentGuiTitle = "&4Admin: edit contents"; + + @Comment({ "", "# Admin root menu buttons" }) + public ConfigItem adminParcelsButton = new ConfigItem() + .type(Material.CHEST) + .name("&6📦 &eParcels") + .lore(List.of("&7» &fBrowse and edit every parcel.")); + public ConfigItem adminLockersButton = new ConfigItem() + .type(Material.ENDER_CHEST) + .name("&6🔒 &eLockers") + .lore(List.of("&7» &fManage parcel lockers.")); + public ConfigItem adminUsersButton = new ConfigItem() + .type(Material.PLAYER_HEAD) + .name("&6👤 &eUsers") + .lore(List.of("&7» &fInspect users and their parcels.")); + public ConfigItem adminDeleteAllParcelsButton = new ConfigItem() + .type(Material.TNT) + .name("&4⚠ &cDelete ALL parcels") + .lore(List.of("&c» &7Irreversible. Asks for confirmation.")); + public ConfigItem adminDeleteAllLockersButton = new ConfigItem() + .type(Material.TNT) + .name("&4⚠ &cDelete ALL lockers") + .lore(List.of("&c» &7Irreversible. Asks for confirmation.")); + + @Comment({ "", "# Admin parcel edit buttons" }) + public ConfigItem adminEditNameButton = new ConfigItem() + .type(Material.NAME_TAG).name("&eName: &f{NAME}").lore(List.of("&7» &fClick to edit.")); + public ConfigItem adminEditDescriptionButton = new ConfigItem() + .type(Material.WRITABLE_BOOK).name("&eDescription").lore(List.of("&f{DESCRIPTION}", "&7» &fClick to edit.")); + public ConfigItem adminEditPriorityButton = new ConfigItem() + .type(Material.BLAZE_POWDER).name("&ePriority: &f{PRIORITY}").lore(List.of("&7» &fClick to toggle.")); + public ConfigItem adminEditSizeButton = new ConfigItem() + .type(Material.SHULKER_BOX).name("&eSize: &f{SIZE}").lore(List.of("&7» &fClick to cycle.")); + public ConfigItem adminEditStatusButton = new ConfigItem() + .type(Material.COMPARATOR).name("&eStatus: &f{STATUS}").lore(List.of("&7» &fClick to cycle.")); + public ConfigItem adminEditReceiverButton = new ConfigItem() + .type(Material.PLAYER_HEAD).name("&eReceiver: &f{RECEIVER}").lore(List.of("&7» &fClick to choose.")); + public ConfigItem adminEditDestinationButton = new ConfigItem() + .type(Material.ENDER_CHEST).name("&eDestination: &f{DESTINATION}").lore(List.of("&7» &fClick to choose.")); + public ConfigItem adminEditContentsButton = new ConfigItem() + .type(Material.CHEST_MINECART).name("&eEdit contents").lore(List.of("&7» &fOpen the item editor.")); + public ConfigItem adminDeleteParcelButton = new ConfigItem() + .type(Material.LAVA_BUCKET).name("&cDelete parcel").lore(List.of("&c» &7Asks for confirmation.")); + + @Comment({ "", "# Admin locker edit buttons" }) + public ConfigItem adminRenameLockerButton = new ConfigItem() + .type(Material.NAME_TAG).name("&eName: &f{NAME}").lore(List.of("&7» &fClick to rename.")); + public ConfigItem adminTeleportLockerButton = new ConfigItem() + .type(Material.ENDER_PEARL).name("&eTeleport").lore(List.of("&7» &fGo to this locker.")); + public ConfigItem adminDeleteLockerButton = new ConfigItem() + .type(Material.LAVA_BUCKET).name("&cDelete locker").lore(List.of("&c» &7Asks for confirmation.")); + + @Comment({ "", "# Admin list row items" }) + public ConfigItem adminParcelRowItem = new ConfigItem() + .type(Material.PAPER).name("&e{NAME}") + .lore(List.of("&7Status: &f{STATUS}", "&7Size: &f{SIZE}", "&7Priority: &f{PRIORITY}", "&8{UUID}", "&7» &fClick to edit.")); + public ConfigItem adminLockerRowItem = new ConfigItem() + .type(Material.CHEST).name("&e{NAME}") + .lore(List.of("&7{POSITION}", "&8{UUID}", "&7» &fClick to manage.")); + public ConfigItem adminUserRowItem = new ConfigItem() + .type(Material.PLAYER_HEAD).name("&e{NAME}") + .lore(List.of("&8{UUID}", "&7» &fClick to inspect.")); + public ConfigItem adminEmptyListItem = new ConfigItem() + .type(Material.BARRIER).name("&cNothing to show"); + @Comment({ "", "# The item of the parcel submit button" }) public ConfigItem submitParcelItem = new ConfigItem() .name("&2✔ &aSubmit") From 989c7d5435363cdf918d575b8793ae2a17c4f609 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:55:44 +0200 Subject: [PATCH 09/21] feat: add reusable confirmation dialog factory for admin GUI Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../admin/ConfirmationDialogFactory.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java new file mode 100644 index 00000000..09b62970 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java @@ -0,0 +1,41 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.dialog.DialogResponseView; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.minimessage.MiniMessage; + +@SuppressWarnings("UnstableApiUsage") +class ConfirmationDialogFactory { + + private final MiniMessage miniMessage; + + ConfirmationDialogFactory(MiniMessage miniMessage) { + this.miniMessage = miniMessage; + } + + Dialog create(String title, String confirmLabel, String cancelLabel, Runnable onConfirm, Runnable onCancel) { + return Dialog.create(builder -> builder.empty() + .base(DialogBase.builder(this.miniMessage.deserialize(title)) + .canCloseWithEscape(true) + .build()) + .type(DialogType.confirmation( + ActionButton.create( + this.miniMessage.deserialize(confirmLabel), + null, + 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> onConfirm.run(), + ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build())), + ActionButton.create( + this.miniMessage.deserialize(cancelLabel), + null, + 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> onCancel.run(), + ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build()))))); + } +} From ece2df5ffb8fc0f5e33432659d6de9d637c2f915 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:59:38 +0200 Subject: [PATCH 10/21] feat: add admin locker list + edit GUIs (rename, teleport, delete) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../parcellockers/gui/GuiManager.java | 8 + .../admin/AdminLockerEditGui.java | 139 ++++++++++++++++++ .../admin/AdminLockerListGui.java | 97 ++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerListGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index 276fdd83..f4b26c3d 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -106,4 +106,12 @@ public CompletableFuture> getParcelContent(UUID parcelId public CompletableFuture> getDelivery(UUID parcelId) { return this.deliveryManager.get(parcelId); } + + public CompletableFuture renameLocker(UUID lockerUuid, String newName) { + return this.lockerManager.rename(lockerUuid, newName); + } + + public CompletableFuture deleteLocker(UUID lockerUuid, UUID actor) { + return this.lockerManager.delete(lockerUuid, actor); + } } diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java new file mode 100644 index 00000000..15fb6639 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java @@ -0,0 +1,139 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.shared.Position; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.dialog.DialogResponseView; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.input.DialogInput; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import java.util.List; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; + +@SuppressWarnings("UnstableApiUsage") +public class AdminLockerEditGui implements GuiView { + + private static final int RENAME_SLOT = 20; + private static final int TELEPORT_SLOT = 22; + private static final int DELETE_SLOT = 24; + private static final int CLOSE_SLOT = 49; + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final MessageConfig messageConfig; + private final NoticeService noticeService; + private final GuiManager guiManager; + private final AdminLockerListGui parent; + private final Locker locker; + private final ConfirmationDialogFactory confirmationDialogFactory; + + public AdminLockerEditGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + MessageConfig messageConfig, NoticeService noticeService, GuiManager guiManager, + AdminLockerListGui parent, Locker locker) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.messageConfig = messageConfig; + this.noticeService = noticeService; + this.guiManager = guiManager; + this.parent = parent; + this.locker = locker; + this.confirmationDialogFactory = new ConfirmationDialogFactory(miniMessage); + } + + @Override + public void show(Player player) { + Gui gui = Gui.gui() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminLockerEditGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + for (int i = 0; i < gui.getRows() * 9; i++) { + gui.setItem(i, background); + } + + ConfigItem renameItem = this.guiSettings.adminRenameLockerButton.clone(); + renameItem.name(this.guiSettings.adminRenameLockerButton.name().replace("{NAME}", this.locker.name())); + gui.setItem(RENAME_SLOT, renameItem.toGuiItem(event -> this.openRenameDialog(player))); + + gui.setItem(TELEPORT_SLOT, this.guiSettings.adminTeleportLockerButton.toGuiItem(event -> this.teleport(player, gui))); + + gui.setItem(DELETE_SLOT, this.guiSettings.adminDeleteLockerButton.toGuiItem(event -> + player.showDialog(this.confirmationDialogFactory.create( + "Delete locker '" + this.locker.name() + "'?", + "Delete", "Cancel", + () -> this.guiManager.deleteLocker(this.locker.uuid(), player.getUniqueId()).thenRun(() -> { + this.noticeService.create().notice(m -> m.admin.lockerDeleted).player(player.getUniqueId()).send(); + this.scheduler.run(() -> this.parent.show(player)); + }).exceptionally(FutureHandler::handleException), + () -> this.scheduler.run(() -> this.show(player)))))); + + gui.setItem(CLOSE_SLOT, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + gui.setDefaultClickAction(event -> event.setCancelled(true)); + this.scheduler.run(() -> gui.open(player)); + } + + private void teleport(Player player, Gui gui) { + Position position = this.locker.position(); + World world = Bukkit.getWorld(position.world()); + if (world == null) { + this.noticeService.create().notice(m -> m.admin.teleportWorldMissing).player(player.getUniqueId()).send(); + return; + } + gui.close(player); + this.scheduler.run(() -> { + player.teleport(new Location(world, position.x() + 0.5, position.y(), position.z() + 0.5)); + this.noticeService.create().notice(m -> m.admin.teleported).player(player.getUniqueId()).send(); + }); + } + + private void openRenameDialog(Player player) { + Dialog dialog = Dialog.create(builder -> builder.empty() + .base(DialogBase.builder(this.miniMessage.deserialize("Enter new locker name:")) + .inputs(List.of(DialogInput.text("name", this.miniMessage.deserialize("Locker name")).build())) + .build()) + .type(DialogType.confirmation( + ActionButton.create(this.miniMessage.deserialize("Confirm"), null, 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> { + String name = view.getText("name"); + if (name == null || name.isBlank()) { + this.scheduler.run(() -> this.show(player)); + return; + } + this.guiManager.renameLocker(this.locker.uuid(), name).thenAccept(renamed -> { + this.noticeService.create().notice(m -> m.admin.lockerRenamed).player(player.getUniqueId()).send(); + this.scheduler.run(() -> + new AdminLockerEditGui(this.scheduler, this.miniMessage, this.guiSettings, + this.messageConfig, this.noticeService, this.guiManager, this.parent, renamed).show(player)); + }).exceptionally(FutureHandler::handleException); + }, ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build())), + ActionButton.create(this.miniMessage.deserialize("Cancel"), null, 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> + this.scheduler.run(() -> this.show(player)), + ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build()))))); + player.showDialog(dialog); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerListGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerListGui.java new file mode 100644 index 00000000..05848a43 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerListGui.java @@ -0,0 +1,97 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.shared.Page; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.util.List; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminLockerListGui implements GuiView { + + private static final int WIDTH = 7; + private static final int HEIGHT = 4; + private static final Page FIRST_PAGE = new Page(0, WIDTH * HEIGHT); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final MessageConfig messageConfig; + private final NoticeService noticeService; + private final GuiManager guiManager; + private final GuiView parent; + + public AdminLockerListGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + MessageConfig messageConfig, NoticeService noticeService, GuiManager guiManager, GuiView parent) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.messageConfig = messageConfig; + this.noticeService = noticeService; + this.guiManager = guiManager; + this.parent = parent; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminLockerListGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + this.guiManager.getLockerPage(page).thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (Locker locker : result.items()) { + gui.addItem(this.createRow(player, locker)); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } + + private GuiItem createRow(Player player, Locker locker) { + ConfigItem row = this.guiSettings.adminLockerRowItem.clone(); + List lore = row.lore().stream() + .map(line -> line.replace("{POSITION}", locker.position().toString()).replace("{UUID}", locker.uuid().toString())) + .toList(); + return row.name(row.name().replace("{NAME}", locker.name())).lore(lore).toGuiItem(event -> + new AdminLockerEditGui(this.scheduler, this.miniMessage, this.guiSettings, this.messageConfig, + this.noticeService, this.guiManager, this, locker).show(player)); + } +} From d75ea0177e53c7c45e71dacd2d25d9749572f294 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:05:14 +0200 Subject: [PATCH 11/21] feat: add admin user list + inspect GUIs Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../admin/AdminUserInspectGui.java | 100 ++++++++++++++++++ .../admin/AdminUserListGui.java | 86 +++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserListGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java new file mode 100644 index 00000000..a11424f4 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java @@ -0,0 +1,100 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.user.User; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.util.List; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminUserInspectGui implements GuiView { + + private static final Page FIRST_PAGE = new Page(0, 28); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final GuiManager guiManager; + private final AdminUserListGui parent; + private final User user; + private final boolean showSent; + + public AdminUserInspectGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + GuiManager guiManager, AdminUserListGui parent, User user, boolean showSent) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.guiManager = guiManager; + this.parent = parent; + this.user = user; + this.showSent = showSent; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminUserInspectGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + var future = this.showSent + ? this.guiManager.getParcelsBySender(this.user.uuid(), page) + : this.guiManager.getParcelsByReceiver(this.user.uuid(), page); + + future.thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (Parcel parcel : result.items()) { + gui.addItem(this.createRow(parcel)); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } + + private GuiItem createRow(Parcel parcel) { + ConfigItem row = this.guiSettings.adminParcelRowItem.clone(); + List lore = row.lore().stream() + .map(line -> line + .replace("{STATUS}", parcel.status().name()) + .replace("{SIZE}", parcel.size().name()) + .replace("{PRIORITY}", parcel.priority() ? "Yes" : "No") + .replace("{UUID}", parcel.uuid().toString())) + .toList(); + return row.name(row.name().replace("{NAME}", parcel.name())).lore(lore).toGuiItem(); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserListGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserListGui.java new file mode 100644 index 00000000..2cc28d67 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserListGui.java @@ -0,0 +1,86 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.user.User; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminUserListGui implements GuiView { + + private static final Page FIRST_PAGE = new Page(0, 28); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final GuiManager guiManager; + private final GuiView parent; + + public AdminUserListGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + GuiManager guiManager, GuiView parent) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.guiManager = guiManager; + this.parent = parent; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminUserListGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + this.guiManager.getUsers(page).thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (User user : result.items()) { + gui.addItem(this.createRow(player, user)); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } + + private GuiItem createRow(Player player, User user) { + ConfigItem row = this.guiSettings.adminUserRowItem.clone(); + return row.name(row.name().replace("{NAME}", user.name())) + .lore(row.lore().stream().map(line -> line.replace("{UUID}", user.uuid().toString())).toList()) + .toGuiItem(event -> new AdminUserInspectGui(this.scheduler, this.miniMessage, this.guiSettings, + this.guiManager, this, user, false).show(player)); + } +} From 3a62498693a2f6707c12a8000059de2eb4d5bee0 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:12:37 +0200 Subject: [PATCH 12/21] feat: add AdminParcelService + delivery update path with size/priority/destination invariants Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../delivery/DeliveryManager.java | 8 + .../repository/DeliveryRepository.java | 2 + .../repository/DeliveryRepositoryOrmLite.java | 5 + .../parcel/service/AdminParcelService.java | 137 ++++++++++++++++++ .../parcel/service/EditResult.java | 28 ++++ .../service/AdminParcelServiceTest.java | 69 +++++++++ 6 files changed, 249 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java create mode 100644 src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java create mode 100644 src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java b/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java index ceeb8edf..d34ae7d7 100644 --- a/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java +++ b/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java @@ -39,6 +39,14 @@ public CompletableFuture> get(UUID parcel) { }); } + public CompletableFuture update(UUID parcel, Instant deliveryTimestamp) { + Delivery delivery = new Delivery(parcel, deliveryTimestamp); + return this.deliveryRepository.update(delivery).thenApply(ignored -> { + this.deliveryCache.put(parcel, delivery); + return delivery; + }); + } + public Delivery create(UUID parcel, Instant deliveryTimestamp) { Delivery delivery = new Delivery(parcel, deliveryTimestamp); if (this.deliveryCache.getIfPresent(parcel) != null) { diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepository.java b/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepository.java index e8e581cc..93049a16 100644 --- a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepository.java +++ b/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepository.java @@ -10,6 +10,8 @@ public interface DeliveryRepository { CompletableFuture save(Delivery delivery); + CompletableFuture update(Delivery delivery); + CompletableFuture> find(UUID parcel); CompletableFuture delete(UUID parcel); diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java index 6a6f6c5a..a2b52c5d 100644 --- a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java @@ -21,6 +21,11 @@ public CompletableFuture save(Delivery delivery) { return this.insertIfAbsent(DeliveryTable.class, DeliveryTable.from(delivery)).thenApply(dao -> null); } + @Override + public CompletableFuture update(Delivery delivery) { + return this.upsert(DeliveryTable.class, DeliveryTable.from(delivery)).thenApply(status -> null); + } + @Override public CompletableFuture> find(UUID parcel) { return this.selectSafe(DeliveryTable.class, parcel) diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java new file mode 100644 index 00000000..1081e33c --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java @@ -0,0 +1,137 @@ +package com.eternalcode.parcellockers.parcel.service; + +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContentManager; +import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.locker.LockerManager; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class AdminParcelService { + + private final ParcelService parcelService; + private final ParcelContentManager parcelContentManager; + private final DeliveryManager deliveryManager; + private final LockerManager lockerManager; + private final PluginConfig config; + + public AdminParcelService(ParcelService parcelService, ParcelContentManager parcelContentManager, + DeliveryManager deliveryManager, LockerManager lockerManager, PluginConfig config) { + this.parcelService = parcelService; + this.parcelContentManager = parcelContentManager; + this.deliveryManager = deliveryManager; + this.lockerManager = lockerManager; + this.config = config; + } + + public static int capacity(ParcelSize size) { + return switch (size) { + case SMALL -> 9; + case MEDIUM -> 18; + case LARGE -> 27; + }; + } + + /** Pure delta-shift helper, clamped to never be before {@code now}. */ + public static Instant shiftedDeliveryTimestamp(Instant oldTimestamp, boolean oldPriority, boolean newPriority, + Duration normalDuration, Duration priorityDuration, Instant now) { + Duration oldDuration = oldPriority ? priorityDuration : normalDuration; + Duration newDuration = newPriority ? priorityDuration : normalDuration; + Instant shifted = oldTimestamp.plus(newDuration).minus(oldDuration); + return shifted.isBefore(now) ? now : shifted; + } + + private CompletableFuture persist(Parcel updated) { + return this.parcelService.update(updated).thenApply(ignored -> EditResult.ok()); + } + + public CompletableFuture changeName(Parcel parcel, String name) { + return this.persist(withName(parcel, name)); + } + + public CompletableFuture changeDescription(Parcel parcel, String description) { + return this.persist(withDescription(parcel, description)); + } + + public CompletableFuture changeStatus(Parcel parcel, ParcelStatus status) { + return this.persist(withStatus(parcel, status)); + } + + public CompletableFuture changeReceiver(Parcel parcel, UUID receiver) { + return this.persist(withReceiver(parcel, receiver)); + } + + public CompletableFuture changeSize(Parcel parcel, ParcelSize newSize) { + return this.parcelContentManager.get(parcel.uuid()).thenCompose(optional -> { + int itemCount = optional.map(content -> content.items().size()).orElse(0); + if (itemCount > capacity(newSize)) { + return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.SIZE_TOO_SMALL)); + } + return this.persist(withSize(parcel, newSize)); + }); + } + + public CompletableFuture changeDestination(Parcel parcel, UUID destinationLocker) { + return this.lockerManager.isLockerFull(destinationLocker).thenCompose(full -> { + if (Boolean.TRUE.equals(full)) { + return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.DESTINATION_FULL)); + } + return this.persist(withDestination(parcel, destinationLocker)); + }); + } + + public CompletableFuture changePriority(Parcel parcel, boolean newPriority) { + Parcel updated = withPriority(parcel, newPriority); + return this.parcelService.update(updated).thenCompose(ignored -> { + if (parcel.status() != ParcelStatus.SENT || newPriority == parcel.priority()) { + return CompletableFuture.completedFuture(EditResult.ok()); + } + return this.deliveryManager.get(parcel.uuid()).thenCompose(optionalDelivery -> { + if (optionalDelivery.isEmpty()) { + return CompletableFuture.completedFuture(EditResult.ok()); + } + Instant shifted = shiftedDeliveryTimestamp( + optionalDelivery.get().deliveryTimestamp(), + parcel.priority(), newPriority, + this.config.settings.parcelSendDuration, + this.config.settings.priorityParcelSendDuration, + Instant.now()); + return this.deliveryManager.update(parcel.uuid(), shifted) + .thenApply(ignoredDelivery -> EditResult.ok()); + }); + }); + } + + private static Parcel withName(Parcel p, String name) { + return new Parcel(p.uuid(), p.sender(), name, p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withDescription(Parcel p, String description) { + return new Parcel(p.uuid(), p.sender(), p.name(), description, p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withPriority(Parcel p, boolean priority) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), priority, p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withSize(Parcel p, ParcelSize size) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), size, p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withStatus(Parcel p, ParcelStatus status) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), status); + } + + private static Parcel withReceiver(Parcel p, UUID receiver) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), receiver, p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withDestination(Parcel p, UUID destinationLocker) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), destinationLocker, p.status()); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java new file mode 100644 index 00000000..c245412e --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java @@ -0,0 +1,28 @@ +package com.eternalcode.parcellockers.parcel.service; + +public final class EditResult { + + public enum Status { OK, SIZE_TOO_SMALL, DESTINATION_FULL } + + private final Status status; + + private EditResult(Status status) { + this.status = status; + } + + public static EditResult ok() { + return new EditResult(Status.OK); + } + + public static EditResult of(Status status) { + return new EditResult(status); + } + + public Status status() { + return this.status; + } + + public boolean isOk() { + return this.status == Status.OK; + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java b/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java new file mode 100644 index 00000000..586e18c2 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java @@ -0,0 +1,69 @@ +package com.eternalcode.parcellockers.parcel.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.eternalcode.parcellockers.parcel.ParcelSize; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class AdminParcelServiceTest { + + @Test + void capacityMatchesContentGuiUsableSlots() { + assertEquals(9, AdminParcelService.capacity(ParcelSize.SMALL)); + assertEquals(18, AdminParcelService.capacity(ParcelSize.MEDIUM)); + assertEquals(27, AdminParcelService.capacity(ParcelSize.LARGE)); + } + + @Test + void enablingPriorityShortensDeliveryByDelta() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofMinutes(5)); // normal delivery scheduled in 5 min + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, false, true, normal, priority, now); + + // delta = priority - normal = -4 min; oldTs - 4 min = now + 1 min + assertEquals(now.plus(Duration.ofMinutes(1)), shifted); + } + + @Test + void disablingPriorityExtendsDeliveryByDelta() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofMinutes(1)); + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, true, false, normal, priority, now); + + // delta = normal - priority = +4 min; oldTs + 4 min = now + 5 min + assertEquals(now.plus(Duration.ofMinutes(5)), shifted); + } + + @Test + void overdueShiftIsClampedToNow() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofSeconds(30)); // 30s left + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + // enabling priority: delta = -4 min, oldTs - 4 min is in the past -> clamp to now + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, false, true, normal, priority, now); + + assertEquals(now, shifted); + } + + @Test + void unchangedPriorityKeepsTimestamp() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofMinutes(3)); + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, true, true, normal, priority, now); + + assertEquals(oldTs, shifted); + } +} From abec09e40f2b0bf9dddb107b0dfbb7083400eb1f Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:18:48 +0200 Subject: [PATCH 13/21] refactor: ParcelSendTask re-fetches state at fire time (safe admin edits) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../parcellockers/ParcelLockers.java | 2 +- .../parcel/service/ParcelDispatchService.java | 3 +- .../parcel/task/ParcelSendTask.java | 86 ++++++++++++------- .../parcel/task/ParcelSendTaskTest.java | 57 ++++++++++++ 4 files changed, 116 insertions(+), 32 deletions(-) create mode 100644 src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 13624ed7..fe7dfea9 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -223,7 +223,7 @@ public void onEnable() { Duration.between(Instant.now(Clock.systemDefaultZone()), delivery.deliveryTimestamp()).toMillis() ); scheduler.runLaterAsync( - new ParcelSendTask(parcel, parcelService, deliveryManager), + new ParcelSendTask(parcel, parcelService, deliveryManager, scheduler), Duration.ofMillis(delay)); }) ))); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java index 3dd92024..9cf928a6 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -103,7 +103,8 @@ private CompletableFuture dispatchInternal(Player sender, Parcel parcel, L ParcelSendTask task = new ParcelSendTask( parcel, this.parcelService, - this.deliveryManager + this.deliveryManager, + this.scheduler ); this.scheduler.runLaterAsync(task, delay); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java index 258c7e7d..7599f0b5 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java @@ -1,10 +1,16 @@ package com.eternalcode.parcellockers.parcel.task; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.delivery.Delivery; import com.eternalcode.parcellockers.delivery.DeliveryManager; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.event.ParcelDeliverEvent; import com.eternalcode.parcellockers.parcel.service.ParcelService; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; import java.util.logging.Logger; import org.bukkit.Bukkit; import org.bukkit.scheduler.BukkitRunnable; @@ -13,53 +19,73 @@ public class ParcelSendTask extends BukkitRunnable { private static final Logger LOGGER = Logger.getLogger(ParcelSendTask.class.getName()); - private final Parcel parcel; + public enum Decision { DELIVER, RESCHEDULE, ABORT } + + private final UUID parcelId; private final ParcelService parcelService; private final DeliveryManager deliveryManager; + private final Scheduler scheduler; - public ParcelSendTask( - Parcel parcel, - ParcelService parcelService, - DeliveryManager deliveryManager - ) { - this.parcel = parcel; + public ParcelSendTask(Parcel parcel, ParcelService parcelService, DeliveryManager deliveryManager, Scheduler scheduler) { + this.parcelId = parcel.uuid(); this.parcelService = parcelService; this.deliveryManager = deliveryManager; + this.scheduler = scheduler; + } + + /** Pure decision: what to do given the latest parcel + delivery state at fire time. */ + public static Decision decide(Optional currentParcel, Optional currentDelivery, Instant now) { + if (currentParcel.isEmpty() || currentParcel.get().status() == ParcelStatus.DELIVERED) { + return Decision.ABORT; + } + if (currentDelivery.isPresent() && currentDelivery.get().deliveryTimestamp().isAfter(now)) { + return Decision.RESCHEDULE; + } + return Decision.DELIVER; } @Override public void run() { - Parcel updated = new Parcel( - this.parcel.uuid(), - this.parcel.sender(), - this.parcel.name(), - this.parcel.description(), - this.parcel.priority(), - this.parcel.receiver(), - this.parcel.size(), - this.parcel.entryLocker(), - this.parcel.destinationLocker(), - ParcelStatus.DELIVERED - ); + this.parcelService.get(this.parcelId).thenCompose(optionalParcel -> + this.deliveryManager.get(this.parcelId).thenAccept(optionalDelivery -> { + Instant now = Instant.now(); + switch (decide(optionalParcel, optionalDelivery, now)) { + case ABORT -> + // Parcel gone or already delivered: clean up any stray delivery row. + optionalDelivery.ifPresent(delivery -> this.deliveryManager.delete(this.parcelId)); + case RESCHEDULE -> { + Duration remaining = Duration.between(now, optionalDelivery.get().deliveryTimestamp()); + // Reschedule a fresh task; this instance ends after this run. + this.scheduler.runLaterAsync( + new ParcelSendTask(optionalParcel.get(), this.parcelService, this.deliveryManager, this.scheduler), + remaining.isNegative() ? Duration.ZERO : remaining); + } + case DELIVER -> this.deliver(optionalParcel.get()); + } + })).exceptionally(throwable -> { + LOGGER.severe("ParcelSendTask failed for " + this.parcelId + ": " + throwable.getMessage()); + return null; + }); + } - // Fire ParcelDeliverEvent - ParcelDeliverEvent event = new ParcelDeliverEvent(updated); + private void deliver(Parcel current) { + Parcel delivered = new Parcel(current.uuid(), current.sender(), current.name(), current.description(), + current.priority(), current.receiver(), current.size(), current.entryLocker(), + current.destinationLocker(), ParcelStatus.DELIVERED); + + ParcelDeliverEvent event = new ParcelDeliverEvent(delivered); Bukkit.getPluginManager().callEvent(event); - if (event.isCancelled()) { - LOGGER.info("ParcelDeliverEvent was cancelled for parcel " + updated.uuid()); + LOGGER.info("ParcelDeliverEvent was cancelled for parcel " + delivered.uuid()); return; } - // Delete the delivery only after the status update succeeds. If the update fails, the - // delivery row is left intact so the task is rescheduled on the next startup; deleting it - // unconditionally could strand the parcel in SENT with no delivery, never to be delivered. - this.parcelService.update(updated) - .thenCompose(ignored -> this.deliveryManager.delete(updated.uuid())) + this.parcelService.update(delivered) + .thenCompose(ignored -> this.deliveryManager.delete(delivered.uuid())) .exceptionally(throwable -> { - LOGGER.severe("Failed to deliver parcel " + updated.uuid() + " (delivery left for retry): " + throwable.getMessage()); + LOGGER.severe("Failed to deliver parcel " + delivered.uuid() + + " (delivery left for retry): " + throwable.getMessage()); return null; }); } - } diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java b/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java new file mode 100644 index 00000000..80dace4d --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java @@ -0,0 +1,57 @@ +package com.eternalcode.parcellockers.parcel.task; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.task.ParcelSendTask.Decision; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ParcelSendTaskTest { + + private static Parcel parcel(ParcelStatus status) { + UUID id = UUID.randomUUID(); + return new Parcel(id, UUID.randomUUID(), "n", "d", false, UUID.randomUUID(), + ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), status); + } + + @Test + void abortsWhenParcelMissing() { + assertEquals(Decision.ABORT, ParcelSendTask.decide(Optional.empty(), Optional.empty(), Instant.now())); + } + + @Test + void abortsWhenAlreadyDelivered() { + Parcel delivered = parcel(ParcelStatus.DELIVERED); + assertEquals(Decision.ABORT, ParcelSendTask.decide(Optional.of(delivered), Optional.empty(), Instant.now())); + } + + @Test + void reschedulesWhenDeliveryMovedToFuture() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Parcel sent = parcel(ParcelStatus.SENT); + Delivery future = new Delivery(sent.uuid(), now.plus(Duration.ofMinutes(2))); + assertEquals(Decision.RESCHEDULE, ParcelSendTask.decide(Optional.of(sent), Optional.of(future), now)); + } + + @Test + void deliversWhenDueAndStillSent() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Parcel sent = parcel(ParcelStatus.SENT); + Delivery due = new Delivery(sent.uuid(), now.minus(Duration.ofSeconds(1))); + assertEquals(Decision.DELIVER, ParcelSendTask.decide(Optional.of(sent), Optional.of(due), now)); + } + + @Test + void deliversWhenSentWithNoDeliveryRow() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Parcel sent = parcel(ParcelStatus.SENT); + assertEquals(Decision.DELIVER, ParcelSendTask.decide(Optional.of(sent), Optional.empty(), now)); + } +} From c6b5a63bfa398d83db464c9ec18bc0078fcb333f Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:22:44 +0200 Subject: [PATCH 14/21] fix: log failures when ParcelSendTask cleans up a stray delivery on ABORT Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../parcellockers/parcel/task/ParcelSendTask.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java index 7599f0b5..49c4eaee 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java @@ -52,7 +52,11 @@ public void run() { switch (decide(optionalParcel, optionalDelivery, now)) { case ABORT -> // Parcel gone or already delivered: clean up any stray delivery row. - optionalDelivery.ifPresent(delivery -> this.deliveryManager.delete(this.parcelId)); + optionalDelivery.ifPresent(delivery -> + this.deliveryManager.delete(this.parcelId).exceptionally(throwable -> { + LOGGER.severe("Failed to delete stray delivery for " + this.parcelId + ": " + throwable.getMessage()); + return false; + })); case RESCHEDULE -> { Duration remaining = Duration.between(now, optionalDelivery.get().deliveryTimestamp()); // Reschedule a fresh task; this instance ends after this run. From e95affa09660880ddfbe0ee82697f6d9561b9a8c Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:25:07 +0200 Subject: [PATCH 15/21] feat: add admin parcel content editor GUI Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../parcellockers/gui/GuiManager.java | 4 + .../admin/AdminParcelContentGui.java | 100 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index f4b26c3d..8a909bf1 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -103,6 +103,10 @@ public CompletableFuture> getParcelContent(UUID parcelId return this.parcelContentManager.get(parcelId); } + public CompletableFuture updateParcelContent(UUID parcelId, List items) { + return this.parcelContentManager.update(parcelId, items); + } + public CompletableFuture> getDelivery(UUID parcelId) { return this.deliveryManager.get(parcelId); } diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java new file mode 100644 index 00000000..62a3b735 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java @@ -0,0 +1,100 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import com.eternalcode.commons.bukkit.ItemUtil; +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.util.MaterialUtil; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.StorageGui; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.IntStream; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public class AdminParcelContentGui { + + private final Scheduler scheduler; + private final GuiSettings guiSettings; + private final MiniMessage miniMessage; + private final GuiManager guiManager; + private final NoticeService noticeService; + private final Parcel parcel; + private final Consumer onClose; + + public AdminParcelContentGui(Scheduler scheduler, GuiSettings guiSettings, MiniMessage miniMessage, + GuiManager guiManager, NoticeService noticeService, Parcel parcel, Consumer onClose) { + this.scheduler = scheduler; + this.guiSettings = guiSettings; + this.miniMessage = miniMessage; + this.guiManager = guiManager; + this.noticeService = noticeService; + this.parcel = parcel; + this.onClose = onClose; + } + + public void show(Player player) { + int rows = switch (this.parcel.size()) { + case SMALL -> 2; + case MEDIUM -> 3; + case LARGE -> 4; + }; + + StorageGui gui = dev.triumphteam.gui.guis.Gui.storage() + .title(this.miniMessage.deserialize(this.guiSettings.adminParcelContentGuiTitle)) + .rows(rows) + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(event -> event.setCancelled(true)); + IntStream.rangeClosed(1, 9).forEach(i -> gui.setItem(gui.getRows(), i, background)); + gui.setItem(gui.getRows(), 5, this.guiSettings.confirmItemsItem.toGuiItem(event -> { + event.setCancelled(true); + gui.close(player); + })); + + gui.setCloseGuiAction(event -> { + ItemStack[] contents = gui.getInventory().getContents(); + List items = new ArrayList<>(); + List illegalItems = new ArrayList<>(); + for (int i = 0; i < contents.length - 9; i++) { + ItemStack item = contents[i]; + if (item == null) { + continue; + } + if (this.guiSettings.illegalItems.contains(item.getType())) { + illegalItems.add(item); + } else { + items.add(item); + } + } + for (ItemStack illegalItem : illegalItems) { + ItemUtil.giveItem(player, illegalItem); + this.noticeService.create() + .notice(messages -> messages.parcel.illegalItem) + .placeholder("{ITEMS}", MaterialUtil.format(illegalItem.getType())) + .player(player.getUniqueId()) + .send(); + } + this.guiManager.updateParcelContent(this.parcel.uuid(), items) + .thenAccept(saved -> { + this.noticeService.create().notice(m -> m.admin.contentsUpdated).player(player.getUniqueId()).send(); + this.scheduler.run(() -> this.onClose.accept(player)); + }) + .exceptionally(throwable -> { + this.scheduler.run(() -> items.forEach(item -> ItemUtil.giveItem(player, item))); + return FutureHandler.handleException(throwable); + }); + }); + + this.guiManager.getParcelContent(this.parcel.uuid()).thenAccept(optional -> { + optional.ifPresent(content -> gui.addItem(content.items().toArray(new ItemStack[0]))); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } +} From 91c1021cb7e3f9c702b988da590331dcd9004b23 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:44:51 +0200 Subject: [PATCH 16/21] feat: add admin parcel list, pickers, and per-field edit GUI Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../parcellockers/gui/GuiManager.java | 8 + .../admin/AdminDestinationPickerGui.java | 86 +++++++ .../admin/AdminParcelEditGui.java | 226 ++++++++++++++++++ .../admin/AdminParcelListGui.java | 100 ++++++++ .../admin/AdminReceiverPickerGui.java | 84 +++++++ 5 files changed, 504 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminDestinationPickerGui.java create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelListGui.java create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminReceiverPickerGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index 8a909bf1..2f575afd 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -118,4 +118,12 @@ public CompletableFuture renameLock public CompletableFuture deleteLocker(UUID lockerUuid, UUID actor) { return this.lockerManager.delete(lockerUuid, actor); } + + public CompletableFuture> getParcel(UUID uuid) { + return this.parcelService.get(uuid); + } + + public CompletableFuture deleteParcel(Parcel parcel) { + return this.parcelService.delete(parcel); + } } diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminDestinationPickerGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminDestinationPickerGui.java new file mode 100644 index 00000000..dae89733 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminDestinationPickerGui.java @@ -0,0 +1,86 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.shared.Page; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.util.function.BiConsumer; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminDestinationPickerGui implements GuiView { + + private static final Page FIRST_PAGE = new Page(0, 28); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final GuiManager guiManager; + private final GuiView parent; + private final BiConsumer onPick; + + public AdminDestinationPickerGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + GuiManager guiManager, GuiView parent, BiConsumer onPick) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.guiManager = guiManager; + this.parent = parent; + this.onPick = onPick; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminLockerListGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + this.guiManager.getLockerPage(page).thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (Locker locker : result.items()) { + ConfigItem row = this.guiSettings.adminLockerRowItem.clone(); + gui.addItem(row.name(row.name().replace("{NAME}", locker.name())) + .lore(row.lore().stream().map(line -> line + .replace("{POSITION}", locker.position().toString()) + .replace("{UUID}", locker.uuid().toString())).toList()) + .toGuiItem(event -> this.onPick.accept(player, locker))); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java new file mode 100644 index 00000000..5cb9713b --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java @@ -0,0 +1,226 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.service.AdminParcelService; +import com.eternalcode.parcellockers.parcel.service.EditResult; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.dialog.DialogResponseView; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.input.DialogInput; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +@SuppressWarnings("UnstableApiUsage") +public class AdminParcelEditGui implements GuiView { + + private static final int NAME_SLOT = 10; + private static final int DESCRIPTION_SLOT = 11; + private static final int PRIORITY_SLOT = 12; + private static final int SIZE_SLOT = 13; + private static final int STATUS_SLOT = 14; + private static final int RECEIVER_SLOT = 15; + private static final int DESTINATION_SLOT = 16; + private static final int CONTENTS_SLOT = 30; + private static final int DELETE_SLOT = 32; + private static final int CLOSE_SLOT = 49; + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final MessageConfig messageConfig; + private final NoticeService noticeService; + private final GuiManager guiManager; + private final AdminParcelService adminParcelService; + private final GuiView parent; + private final Parcel parcel; + private final ConfirmationDialogFactory confirmationDialogFactory; + + public AdminParcelEditGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + MessageConfig messageConfig, NoticeService noticeService, GuiManager guiManager, + AdminParcelService adminParcelService, GuiView parent, Parcel parcel) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.messageConfig = messageConfig; + this.noticeService = noticeService; + this.guiManager = guiManager; + this.adminParcelService = adminParcelService; + this.parent = parent; + this.parcel = parcel; + this.confirmationDialogFactory = new ConfirmationDialogFactory(miniMessage); + } + + @Override + public void show(Player player) { + Gui gui = Gui.gui() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminParcelEditGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + for (int i = 0; i < gui.getRows() * 9; i++) { + gui.setItem(i, background); + } + + gui.setItem(NAME_SLOT, this.button(this.guiSettings.adminEditNameButton, "{NAME}", this.parcel.name(), + event -> this.openTextDialog(player, "Enter parcel name:", "Name", name -> { + if (name == null || name.isBlank()) { + this.scheduler.run(() -> this.show(player)); + return; + } + this.apply(player, this.adminParcelService.changeName(this.parcel, name)); + }))); + + gui.setItem(DESCRIPTION_SLOT, this.button(this.guiSettings.adminEditDescriptionButton, "{DESCRIPTION}", this.parcel.description(), + event -> this.openTextDialog(player, "Enter description:", "Description", description -> + this.apply(player, this.adminParcelService.changeDescription(this.parcel, description))))); + + gui.setItem(PRIORITY_SLOT, this.button(this.guiSettings.adminEditPriorityButton, "{PRIORITY}", this.parcel.priority() ? "Yes" : "No", + event -> this.applyPriority(player, !this.parcel.priority()))); + + gui.setItem(SIZE_SLOT, this.button(this.guiSettings.adminEditSizeButton, "{SIZE}", this.parcel.size().name(), + event -> this.applySize(player, nextSize(this.parcel.size())))); + + gui.setItem(STATUS_SLOT, this.button(this.guiSettings.adminEditStatusButton, "{STATUS}", this.parcel.status().name(), + event -> this.applyStatus(player, nextStatus(this.parcel.status())))); + + gui.setItem(RECEIVER_SLOT, this.button(this.guiSettings.adminEditReceiverButton, "{RECEIVER}", this.parcel.receiver().toString(), + event -> new AdminReceiverPickerGui(this.scheduler, this.miniMessage, this.guiSettings, this.guiManager, this, + (p, user) -> this.apply(p, this.adminParcelService.changeReceiver(this.parcel, user.uuid()))).show(player))); + + gui.setItem(DESTINATION_SLOT, this.button(this.guiSettings.adminEditDestinationButton, "{DESTINATION}", this.parcel.destinationLocker().toString(), + event -> new AdminDestinationPickerGui(this.scheduler, this.miniMessage, this.guiSettings, this.guiManager, this, + (p, locker) -> this.applyDestination(p, locker.uuid())).show(player))); + + gui.setItem(CONTENTS_SLOT, this.guiSettings.adminEditContentsButton.toGuiItem(event -> + new AdminParcelContentGui(this.scheduler, this.guiSettings, this.miniMessage, this.guiManager, + this.noticeService, this.parcel, this::reopenFresh).show(player))); + + gui.setItem(DELETE_SLOT, this.guiSettings.adminDeleteParcelButton.toGuiItem(event -> + player.showDialog(this.confirmationDialogFactory.create( + "Delete parcel '" + this.parcel.name() + "'?", + "Delete", "Cancel", + () -> this.guiManager.deleteParcel(this.parcel).thenRun(() -> { + this.noticeService.create().notice(m -> m.admin.parcelDeleted).player(player.getUniqueId()).send(); + this.scheduler.run(() -> this.parent.show(player)); + }).exceptionally(FutureHandler::handleException), + () -> this.scheduler.run(() -> this.show(player)))))); + + gui.setItem(CLOSE_SLOT, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + gui.setDefaultClickAction(event -> event.setCancelled(true)); + this.scheduler.run(() -> gui.open(player)); + } + + private GuiItem button(ConfigItem template, String placeholder, String value, dev.triumphteam.gui.components.GuiAction action) { + ConfigItem item = template.clone(); + return item.name(item.name().replace(placeholder, value)) + .lore(item.lore().stream().map(line -> line.replace(placeholder, value)).toList()) + .toGuiItem(action); + } + + private void apply(Player player, CompletableFuture future) { + future.thenAccept(result -> { + this.notifyResult(player, result); + this.scheduler.run(() -> this.reopenFresh(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void applyPriority(Player player, boolean priority) { + this.adminParcelService.changePriority(this.parcel, priority).thenAccept(result -> { + this.noticeService.create().notice(m -> m.admin.priorityUpdated).player(player.getUniqueId()).send(); + this.scheduler.run(() -> this.reopenFresh(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void applySize(Player player, ParcelSize size) { + this.adminParcelService.changeSize(this.parcel, size).thenAccept(result -> { + this.notifyResult(player, result); + this.scheduler.run(() -> this.reopenFresh(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void applyStatus(Player player, ParcelStatus status) { + this.adminParcelService.changeStatus(this.parcel, status).thenAccept(result -> { + this.notifyResult(player, result); + this.scheduler.run(() -> this.reopenFresh(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void applyDestination(Player player, UUID destination) { + this.adminParcelService.changeDestination(this.parcel, destination).thenAccept(result -> { + this.notifyResult(player, result); + this.scheduler.run(() -> this.reopenFresh(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void notifyResult(Player player, EditResult result) { + switch (result.status()) { + case OK -> this.noticeService.create().notice(m -> m.admin.parcelUpdated).player(player.getUniqueId()).send(); + case SIZE_TOO_SMALL -> this.noticeService.create().notice(m -> m.admin.sizeTooSmall).player(player.getUniqueId()).send(); + case DESTINATION_FULL -> this.noticeService.create().notice(m -> m.admin.destinationFull).player(player.getUniqueId()).send(); + } + } + + /** Re-fetches the parcel so the editor reflects the just-applied change, then reopens. */ + private void reopenFresh(Player player) { + this.guiManager.getParcel(this.parcel.uuid()).thenAccept(optional -> { + if (optional.isEmpty()) { + this.scheduler.run(() -> this.parent.show(player)); + return; + } + this.scheduler.run(() -> new AdminParcelEditGui(this.scheduler, this.miniMessage, this.guiSettings, + this.messageConfig, this.noticeService, this.guiManager, this.adminParcelService, this.parent, optional.get()).show(player)); + }).exceptionally(FutureHandler::handleException); + } + + private void openTextDialog(Player player, String title, String placeholder, Consumer onConfirm) { + Dialog dialog = Dialog.create(builder -> builder.empty() + .base(DialogBase.builder(this.miniMessage.deserialize(title)) + .inputs(List.of(DialogInput.text("value", this.miniMessage.deserialize(placeholder)).build())) + .build()) + .type(DialogType.confirmation( + ActionButton.create(this.miniMessage.deserialize("Confirm"), null, 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> onConfirm.accept(view.getText("value")), + ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build())), + ActionButton.create(this.miniMessage.deserialize("Cancel"), null, 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> this.scheduler.run(() -> this.show(player)), + ClickCallback.Options.builder().uses(1).lifetime(ClickCallback.DEFAULT_LIFETIME).build()))))); + player.showDialog(dialog); + } + + private static ParcelSize nextSize(ParcelSize size) { + return switch (size) { + case SMALL -> ParcelSize.MEDIUM; + case MEDIUM -> ParcelSize.LARGE; + case LARGE -> ParcelSize.SMALL; + }; + } + + private static ParcelStatus nextStatus(ParcelStatus status) { + return status == ParcelStatus.SENT ? ParcelStatus.DELIVERED : ParcelStatus.SENT; + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelListGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelListGui.java new file mode 100644 index 00000000..c1c9c73f --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelListGui.java @@ -0,0 +1,100 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.service.AdminParcelService; +import com.eternalcode.parcellockers.shared.Page; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminParcelListGui implements GuiView { + + private static final Page FIRST_PAGE = new Page(0, 28); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final MessageConfig messageConfig; + private final NoticeService noticeService; + private final GuiManager guiManager; + private final AdminParcelService adminParcelService; + private final GuiView parent; + + public AdminParcelListGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + MessageConfig messageConfig, NoticeService noticeService, GuiManager guiManager, + AdminParcelService adminParcelService, GuiView parent) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.messageConfig = messageConfig; + this.noticeService = noticeService; + this.guiManager = guiManager; + this.adminParcelService = adminParcelService; + this.parent = parent; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminParcelListGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + this.guiManager.getAllParcels(page).thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (Parcel parcel : result.items()) { + gui.addItem(this.createRow(player, parcel)); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } + + private GuiItem createRow(Player player, Parcel parcel) { + ConfigItem row = this.guiSettings.adminParcelRowItem.clone(); + return row.name(row.name().replace("{NAME}", parcel.name())) + .lore(row.lore().stream().map(line -> line + .replace("{STATUS}", parcel.status().name()) + .replace("{SIZE}", parcel.size().name()) + .replace("{PRIORITY}", parcel.priority() ? "Yes" : "No") + .replace("{UUID}", parcel.uuid().toString())).toList()) + .toGuiItem(event -> new AdminParcelEditGui(this.scheduler, this.miniMessage, this.guiSettings, + this.messageConfig, this.noticeService, this.guiManager, this.adminParcelService, this, parcel).show(player)); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminReceiverPickerGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminReceiverPickerGui.java new file mode 100644 index 00000000..61e98984 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminReceiverPickerGui.java @@ -0,0 +1,84 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.user.User; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.util.function.BiConsumer; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +public class AdminReceiverPickerGui implements GuiView { + + private static final Page FIRST_PAGE = new Page(0, 28); + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final GuiManager guiManager; + private final GuiView parent; + private final BiConsumer onPick; + + public AdminReceiverPickerGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, + GuiManager guiManager, GuiView parent, BiConsumer onPick) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.guiManager = guiManager; + this.parent = parent; + this.onPick = onPick; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + PaginatedGui gui = Gui.paginated() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminUserListGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + for (int slot : BORDER_SLOTS) { + gui.setItem(slot, background); + } + gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + + this.guiManager.getUsers(page).thenAccept(result -> { + if (result.items().isEmpty() && page.hasPrevious()) { + this.show(player, page.previous()); + return; + } + if (result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.adminEmptyListItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (User user : result.items()) { + ConfigItem row = this.guiSettings.adminUserRowItem.clone(); + gui.addItem(row.name(row.name().replace("{NAME}", user.name())) + .lore(row.lore().stream().map(line -> line.replace("{UUID}", user.uuid().toString())).toList()) + .toGuiItem(event -> this.onPick.accept(player, user))); + } + this.setupNavigation(gui, page, result, player, this.guiSettings); + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + } +} From 201cd6f95a4985dfebac849a944ef6381cf914bf Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:51:03 +0200 Subject: [PATCH 17/21] feat: add admin GUI root, bulk actions, and /parcellockers admin entry Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../parcellockers/ParcelLockers.java | 11 ++- .../parcellockers/ParcelLockersCommand.java | 10 +- .../parcellockers/gui/GuiManager.java | 10 ++ .../gui/implementation/admin/AdminGui.java | 99 +++++++++++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index fe7dfea9..7d16eb5a 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -22,6 +22,7 @@ import com.eternalcode.parcellockers.discord.DiscordProviderPicker; import com.eternalcode.parcellockers.discord.argument.SnowflakeArgument; import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.implementation.admin.AdminGui; import com.eternalcode.parcellockers.gui.implementation.locker.LockerGui; import com.eternalcode.parcellockers.gui.implementation.remote.MainGui; import com.eternalcode.parcellockers.itemstorage.ItemStorageManager; @@ -38,6 +39,7 @@ import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.command.ParcelCommand; import com.eternalcode.parcellockers.parcel.repository.ParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.parcel.service.AdminParcelService; import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; import com.eternalcode.parcellockers.parcel.service.ParcelService; import com.eternalcode.parcellockers.parcel.service.ParcelServiceImpl; @@ -177,6 +179,13 @@ public void onEnable() { noticeService ); + AdminParcelService adminParcelService = new AdminParcelService( + parcelService, parcelContentManager, deliveryManager, lockerManager, config); + + AdminGui adminGUI = new AdminGui( + scheduler, miniMessage, config.guiSettings, messageConfig, + noticeService, guiManager, adminParcelService); + LiteCommandsBuilder liteCommandsBuilder = LiteBukkitFactory.builder(this.getName(), this) .extension(new LiteAdventureExtension<>()) .argument(Snowflake.class, new SnowflakeArgument(messageConfig)) @@ -184,7 +193,7 @@ public void onEnable() { .message(LiteBukkitMessages.PLAYER_NOT_FOUND, messageConfig.playerNotFound) .commands(LiteCommandsAnnotations.of( new ParcelCommand(mainGUI), - new ParcelLockersCommand(configService, config, noticeService), + new ParcelLockersCommand(configService, config, noticeService, adminGUI), new DebugCommand( parcelService, lockerManager, itemStorageManager, parcelContentManager, noticeService, deliveryManager) diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java index 8fb2f764..d42846e7 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java @@ -2,6 +2,7 @@ import com.eternalcode.parcellockers.configuration.ConfigService; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.gui.implementation.admin.AdminGui; import com.eternalcode.parcellockers.notification.NoticeService; import dev.rollczi.litecommands.annotations.command.Command; import dev.rollczi.litecommands.annotations.context.Sender; @@ -18,11 +19,13 @@ public class ParcelLockersCommand { private final ConfigService configManager; private final PluginConfig config; private final NoticeService noticeService; + private final AdminGui adminGui; - public ParcelLockersCommand(ConfigService configManager, PluginConfig config, NoticeService noticeService) { + public ParcelLockersCommand(ConfigService configManager, PluginConfig config, NoticeService noticeService, AdminGui adminGui) { this.configManager = configManager; this.config = config; this.noticeService = noticeService; + this.adminGui = adminGui; } @Execute(name = "reload") @@ -40,4 +43,9 @@ void get(@Sender Player player) { player.getInventory().addItem(lockerItem); this.noticeService.player(player.getUniqueId(), messages -> messages.locker.addedToInventory); } + + @Execute(name = "admin") + void admin(@Sender Player player) { + this.adminGui.show(player); + } } diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index 2f575afd..185d8da3 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -11,6 +11,7 @@ import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.user.User; @@ -19,6 +20,7 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -126,4 +128,12 @@ public CompletableFuture> getParcel(UUID uuid) { public CompletableFuture deleteParcel(Parcel parcel) { return this.parcelService.delete(parcel); } + + public CompletableFuture deleteAllParcels(CommandSender sender, NoticeService noticeService) { + return this.parcelService.deleteAll(sender, noticeService); + } + + public CompletableFuture deleteAllLockers(CommandSender sender, NoticeService noticeService) { + return this.lockerManager.deleteAll(sender, noticeService); + } } diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java new file mode 100644 index 00000000..78795cbc --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java @@ -0,0 +1,99 @@ +package com.eternalcode.parcellockers.gui.implementation.admin; + +import static com.eternalcode.commons.adventure.AdventureUtil.resetItalic; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.service.AdminParcelService; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +@SuppressWarnings("UnstableApiUsage") +public class AdminGui implements GuiView { + + private static final int PARCELS_SLOT = 20; + private static final int LOCKERS_SLOT = 22; + private static final int USERS_SLOT = 24; + private static final int DELETE_PARCELS_SLOT = 38; + private static final int DELETE_LOCKERS_SLOT = 42; + private static final int CLOSE_SLOT = 49; + + private final Scheduler scheduler; + private final MiniMessage miniMessage; + private final GuiSettings guiSettings; + private final MessageConfig messageConfig; + private final NoticeService noticeService; + private final GuiManager guiManager; + private final AdminParcelService adminParcelService; + private final ConfirmationDialogFactory confirmationDialogFactory; + + public AdminGui(Scheduler scheduler, MiniMessage miniMessage, GuiSettings guiSettings, MessageConfig messageConfig, + NoticeService noticeService, GuiManager guiManager, AdminParcelService adminParcelService) { + this.scheduler = scheduler; + this.miniMessage = miniMessage; + this.guiSettings = guiSettings; + this.messageConfig = messageConfig; + this.noticeService = noticeService; + this.guiManager = guiManager; + this.adminParcelService = adminParcelService; + this.confirmationDialogFactory = new ConfirmationDialogFactory(miniMessage); + } + + @Override + public void show(Player player) { + Gui gui = Gui.gui() + .title(resetItalic(this.miniMessage.deserialize(this.guiSettings.adminGuiTitle))) + .rows(6) + .disableAllInteractions() + .create(); + + GuiItem background = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + GuiItem corner = this.guiSettings.cornerItem.toGuiItem(); + for (int i = 0; i < gui.getRows() * 9; i++) { + gui.setItem(i, background); + } + for (int slot : CORNER_SLOTS) { + gui.setItem(slot, corner); + } + + gui.setItem(PARCELS_SLOT, this.guiSettings.adminParcelsButton.toGuiItem(event -> + new AdminParcelListGui(this.scheduler, this.miniMessage, this.guiSettings, this.messageConfig, + this.noticeService, this.guiManager, this.adminParcelService, this).show(player))); + + gui.setItem(LOCKERS_SLOT, this.guiSettings.adminLockersButton.toGuiItem(event -> + new AdminLockerListGui(this.scheduler, this.miniMessage, this.guiSettings, this.messageConfig, + this.noticeService, this.guiManager, this).show(player))); + + gui.setItem(USERS_SLOT, this.guiSettings.adminUsersButton.toGuiItem(event -> + new AdminUserListGui(this.scheduler, this.miniMessage, this.guiSettings, this.guiManager, this).show(player))); + + gui.setItem(DELETE_PARCELS_SLOT, this.guiSettings.adminDeleteAllParcelsButton.toGuiItem(event -> + player.showDialog(this.confirmationDialogFactory.create( + "Delete ALL parcels? This cannot be undone.", + "Delete all", "Cancel", + () -> this.guiManager.deleteAllParcels(player, this.noticeService) + .thenRun(() -> this.scheduler.run(() -> this.show(player))) + .exceptionally(FutureHandler::handleException), + () -> this.scheduler.run(() -> this.show(player)))))); + + gui.setItem(DELETE_LOCKERS_SLOT, this.guiSettings.adminDeleteAllLockersButton.toGuiItem(event -> + player.showDialog(this.confirmationDialogFactory.create( + "Delete ALL lockers? This cannot be undone.", + "Delete all", "Cancel", + () -> this.guiManager.deleteAllLockers(player, this.noticeService) + .thenRun(() -> this.scheduler.run(() -> this.show(player))) + .exceptionally(FutureHandler::handleException), + () -> this.scheduler.run(() -> this.show(player)))))); + + gui.setItem(CLOSE_SLOT, this.guiSettings.closeItem.toGuiItem(event -> gui.close(player))); + gui.setDefaultClickAction(event -> event.setCancelled(true)); + this.scheduler.run(() -> gui.open(player)); + } +} From 3d1149edc52f38354aa12b6b2ac466851b041922 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:03:10 +0200 Subject: [PATCH 18/21] fix: cascade content deletion on parcel delete + escape names in admin delete dialogs Final-review fixes: - ParcelServiceImpl.delete(UUID)/deleteAll now also delete the parcel's ParcelContent rows, mirroring the collect/rollback cleanup paths. The admin delete and delete-all buttons previously orphaned content rows (an unbounded leak now that deletion is a one-click bulk operation). - AdminParcelEditGui/AdminLockerEditGui escape player-set parcel/locker names with MiniMessage.escapeTags before interpolating them into delete-confirmation dialog titles, closing a markup-injection vector (Dialog click events execute with the clicking admin's permissions). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../admin/AdminLockerEditGui.java | 2 +- .../admin/AdminParcelEditGui.java | 2 +- .../parcel/service/ParcelServiceImpl.java | 34 +++++++++++++------ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java index 15fb6639..f273571b 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java @@ -83,7 +83,7 @@ public void show(Player player) { gui.setItem(DELETE_SLOT, this.guiSettings.adminDeleteLockerButton.toGuiItem(event -> player.showDialog(this.confirmationDialogFactory.create( - "Delete locker '" + this.locker.name() + "'?", + "Delete locker '" + this.miniMessage.escapeTags(this.locker.name()) + "'?", "Delete", "Cancel", () -> this.guiManager.deleteLocker(this.locker.uuid(), player.getUniqueId()).thenRun(() -> { this.noticeService.create().notice(m -> m.admin.lockerDeleted).player(player.getUniqueId()).send(); diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java index 5cb9713b..bea26810 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java @@ -122,7 +122,7 @@ public void show(Player player) { gui.setItem(DELETE_SLOT, this.guiSettings.adminDeleteParcelButton.toGuiItem(event -> player.showDialog(this.confirmationDialogFactory.create( - "Delete parcel '" + this.parcel.name() + "'?", + "Delete parcel '" + this.miniMessage.escapeTags(this.parcel.name()) + "'?", "Delete", "Cancel", () -> this.guiManager.deleteParcel(this.parcel).thenRun(() -> { this.noticeService.create().notice(m -> m.admin.parcelDeleted).player(player.getUniqueId()).send(); 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 16823ad7..0ecde6da 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -322,16 +322,21 @@ public CompletableFuture> getAll(Page page) { @Override public CompletableFuture delete(UUID uuid) { Objects.requireNonNull(uuid, "UUID cannot be null"); - return this.parcelRepository.delete(uuid).thenApply(deleted -> { - if (deleted) { - Parcel cached = this.parcelsByUuid.getIfPresent(uuid); - if (cached != null) { - this.parcelsByUuid.invalidate(cached.uuid()); - } else { - this.parcelsByUuid.invalidate(uuid); - } + return this.parcelRepository.delete(uuid).thenCompose(deleted -> { + if (!deleted) { + return CompletableFuture.completedFuture(false); } - return deleted; + this.parcelsByUuid.invalidate(uuid); + // The parcel row is gone, so reclaim its content row to avoid an orphaned leak. + // A failed content delete only leaves an orphaned row (logged); it never affects + // the already-deleted parcel, so the operation still reports success. + return this.parcelContentRepository.delete(uuid) + .exceptionally(throwable -> { + this.server.getLogger().warning("Failed to delete content for deleted parcel " + + uuid + ": " + throwable.getMessage()); + return false; + }) + .thenApply(contentDeleted -> true); }); } @@ -346,7 +351,7 @@ public CompletableFuture deleteAll(CommandSender sender, NoticeService not Objects.requireNonNull(sender, "Sender cannot be null"); Objects.requireNonNull(noticeService, "NoticeService cannot be null"); - return this.parcelRepository.deleteAll().thenAccept(deleted -> { + return this.parcelRepository.deleteAll().thenCompose(deleted -> { noticeService.create() .notice(messages -> messages.admin.deletedParcels) .viewer(sender) @@ -354,6 +359,15 @@ public CompletableFuture deleteAll(CommandSender sender, NoticeService not .send(); this.parcelsByUuid.invalidateAll(); + + // Reclaim every content row alongside the parcels so a bulk delete leaves nothing orphaned. + return this.parcelContentRepository.deleteAll() + .exceptionally(throwable -> { + this.server.getLogger().warning("Failed to delete parcel contents during bulk delete: " + + throwable.getMessage()); + return 0; + }) + .thenAccept(contentDeleted -> {}); }); } } From 23a7b64d5ecb798f81afaa87f91e745ddd31b93f Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:31:04 +0200 Subject: [PATCH 19/21] feat: add sent/received toggle to admin user inspect GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review (gemini-code-assist): AdminUserInspectGui already had a showSent code path (getParcelsBySender) but it was unreachable — AdminUserListGui hardcoded showSent=false and there was no in-GUI control. The design doc requires inspecting a user's received AND sent parcels, so add a toggle button (slot 48) that flips the mode and reopens, backed by a new configurable adminToggleSentReceivedButton item. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NmSMZXMtfG9F2HBtrPXU16 --- .../configuration/implementation/PluginConfig.java | 3 +++ .../gui/implementation/admin/AdminUserInspectGui.java | 7 +++++++ 2 files changed, 10 insertions(+) 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 000930b2..ec708c9e 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java @@ -249,6 +249,9 @@ public static class GuiSettings extends OkaeriConfig { public ConfigItem adminUserRowItem = new ConfigItem() .type(Material.PLAYER_HEAD).name("&e{NAME}") .lore(List.of("&8{UUID}", "&7» &fClick to inspect.")); + public ConfigItem adminToggleSentReceivedButton = new ConfigItem() + .type(Material.COMPASS).name("&eShowing: &f{MODE}") + .lore(List.of("&7» &fClick to switch between sent and received.")); public ConfigItem adminEmptyListItem = new ConfigItem() .type(Material.BARRIER).name("&cNothing to show"); diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java index a11424f4..a3ede15c 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java @@ -64,6 +64,13 @@ public void show(Player player, Page page) { } gui.setItem(49, this.guiSettings.closeItem.toGuiItem(event -> this.parent.show(player))); + String mode = this.showSent ? "Sent" : "Received"; + ConfigItem toggle = this.guiSettings.adminToggleSentReceivedButton.clone(); + gui.setItem(48, toggle.name(toggle.name().replace("{MODE}", mode)) + .lore(toggle.lore().stream().map(line -> line.replace("{MODE}", mode)).toList()) + .toGuiItem(event -> new AdminUserInspectGui(this.scheduler, this.miniMessage, this.guiSettings, + this.guiManager, this.parent, this.user, !this.showSent).show(player))); + var future = this.showSent ? this.guiManager.getParcelsBySender(this.user.uuid(), page) : this.guiManager.getParcelsByReceiver(this.user.uuid(), page); From 223c02c674f0631ca02bb04dfb7a88af7784a2ac Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:58:58 +0200 Subject: [PATCH 20/21] Adjust to self-review --- .../implementation/PluginConfig.java | 67 ++++++++++++++----- .../parcellockers/gui/GuiManager.java | 6 +- 2 files changed, 53 insertions(+), 20 deletions(-) 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 ec708c9e..476fc339 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java @@ -194,18 +194,22 @@ public static class GuiSettings extends OkaeriConfig { .type(Material.CHEST) .name("&6📦 &eParcels") .lore(List.of("&7» &fBrowse and edit every parcel.")); + public ConfigItem adminLockersButton = new ConfigItem() .type(Material.ENDER_CHEST) .name("&6🔒 &eLockers") .lore(List.of("&7» &fManage parcel lockers.")); + public ConfigItem adminUsersButton = new ConfigItem() .type(Material.PLAYER_HEAD) .name("&6👤 &eUsers") .lore(List.of("&7» &fInspect users and their parcels.")); + public ConfigItem adminDeleteAllParcelsButton = new ConfigItem() .type(Material.TNT) .name("&4⚠ &cDelete ALL parcels") .lore(List.of("&c» &7Irreversible. Asks for confirmation.")); + public ConfigItem adminDeleteAllLockersButton = new ConfigItem() .type(Material.TNT) .name("&4⚠ &cDelete ALL lockers") @@ -213,47 +217,76 @@ public static class GuiSettings extends OkaeriConfig { @Comment({ "", "# Admin parcel edit buttons" }) public ConfigItem adminEditNameButton = new ConfigItem() - .type(Material.NAME_TAG).name("&eName: &f{NAME}").lore(List.of("&7» &fClick to edit.")); + .type(Material.NAME_TAG) + .name("&eName: &f{NAME}") + .lore(List.of("&7» &fClick to edit.")); public ConfigItem adminEditDescriptionButton = new ConfigItem() - .type(Material.WRITABLE_BOOK).name("&eDescription").lore(List.of("&f{DESCRIPTION}", "&7» &fClick to edit.")); + .type(Material.WRITABLE_BOOK) + .name("&eDescription") + .lore(List.of("&f{DESCRIPTION}", "&7» &fClick to edit.")); public ConfigItem adminEditPriorityButton = new ConfigItem() - .type(Material.BLAZE_POWDER).name("&ePriority: &f{PRIORITY}").lore(List.of("&7» &fClick to toggle.")); + .type(Material.BLAZE_POWDER) + .name("&ePriority: &f{PRIORITY}") + .lore(List.of("&7» &fClick to toggle.")); public ConfigItem adminEditSizeButton = new ConfigItem() - .type(Material.SHULKER_BOX).name("&eSize: &f{SIZE}").lore(List.of("&7» &fClick to cycle.")); + .type(Material.SHULKER_BOX) + .name("&eSize: &f{SIZE}") + .lore(List.of("&7» &fClick to cycle.")); public ConfigItem adminEditStatusButton = new ConfigItem() - .type(Material.COMPARATOR).name("&eStatus: &f{STATUS}").lore(List.of("&7» &fClick to cycle.")); + .type(Material.COMPARATOR) + .name("&eStatus: &f{STATUS}") + .lore(List.of("&7» &fClick to cycle.")); public ConfigItem adminEditReceiverButton = new ConfigItem() - .type(Material.PLAYER_HEAD).name("&eReceiver: &f{RECEIVER}").lore(List.of("&7» &fClick to choose.")); + .type(Material.PLAYER_HEAD) + .name("&eReceiver: &f{RECEIVER}") + .lore(List.of("&7» &fClick to choose.")); public ConfigItem adminEditDestinationButton = new ConfigItem() - .type(Material.ENDER_CHEST).name("&eDestination: &f{DESTINATION}").lore(List.of("&7» &fClick to choose.")); + .type(Material.ENDER_CHEST) + .name("&eDestination: &f{DESTINATION}") + .lore(List.of("&7» &fClick to choose.")); public ConfigItem adminEditContentsButton = new ConfigItem() - .type(Material.CHEST_MINECART).name("&eEdit contents").lore(List.of("&7» &fOpen the item editor.")); + .type(Material.CHEST_MINECART) + .name("&eEdit contents") + .lore(List.of("&7» &fOpen the item editor.")); public ConfigItem adminDeleteParcelButton = new ConfigItem() - .type(Material.LAVA_BUCKET).name("&cDelete parcel").lore(List.of("&c» &7Asks for confirmation.")); + .type(Material.LAVA_BUCKET) + .name("&cDelete parcel") + .lore(List.of("&c» &7Asks for confirmation.")); @Comment({ "", "# Admin locker edit buttons" }) public ConfigItem adminRenameLockerButton = new ConfigItem() - .type(Material.NAME_TAG).name("&eName: &f{NAME}").lore(List.of("&7» &fClick to rename.")); + .type(Material.NAME_TAG) + .name("&eName: &f{NAME}") + .lore(List.of("&7» &fClick to rename.")); public ConfigItem adminTeleportLockerButton = new ConfigItem() - .type(Material.ENDER_PEARL).name("&eTeleport").lore(List.of("&7» &fGo to this locker.")); + .type(Material.ENDER_PEARL) + .name("&eTeleport") + .lore(List.of("&7» &fGo to this locker.")); public ConfigItem adminDeleteLockerButton = new ConfigItem() - .type(Material.LAVA_BUCKET).name("&cDelete locker").lore(List.of("&c» &7Asks for confirmation.")); + .type(Material.LAVA_BUCKET) + .name("&cDelete locker") + .lore(List.of("&c» &7Asks for confirmation.")); @Comment({ "", "# Admin list row items" }) public ConfigItem adminParcelRowItem = new ConfigItem() - .type(Material.PAPER).name("&e{NAME}") + .type(Material.PAPER) + .name("&e{NAME}") .lore(List.of("&7Status: &f{STATUS}", "&7Size: &f{SIZE}", "&7Priority: &f{PRIORITY}", "&8{UUID}", "&7» &fClick to edit.")); public ConfigItem adminLockerRowItem = new ConfigItem() - .type(Material.CHEST).name("&e{NAME}") + .type(Material.CHEST) + .name("&e{NAME}") .lore(List.of("&7{POSITION}", "&8{UUID}", "&7» &fClick to manage.")); public ConfigItem adminUserRowItem = new ConfigItem() - .type(Material.PLAYER_HEAD).name("&e{NAME}") + .type(Material.PLAYER_HEAD) + .name("&e{NAME}") .lore(List.of("&8{UUID}", "&7» &fClick to inspect.")); public ConfigItem adminToggleSentReceivedButton = new ConfigItem() - .type(Material.COMPASS).name("&eShowing: &f{MODE}") + .type(Material.COMPASS) + .name("&eShowing: &f{MODE}") .lore(List.of("&7» &fClick to switch between sent and received.")); public ConfigItem adminEmptyListItem = new ConfigItem() - .type(Material.BARRIER).name("&cNothing to show"); + .type(Material.BARRIER) + .name("&cNothing to show"); @Comment({ "", "# The item of the parcel submit button" }) public ConfigItem submitParcelItem = new ConfigItem() diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index 185d8da3..f6852e7a 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -8,10 +8,10 @@ import com.eternalcode.parcellockers.itemstorage.ItemStorageManager; import com.eternalcode.parcellockers.locker.Locker; import com.eternalcode.parcellockers.locker.LockerManager; +import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; import com.eternalcode.parcellockers.parcel.service.ParcelService; -import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.user.User; @@ -105,7 +105,7 @@ public CompletableFuture> getParcelContent(UUID parcelId return this.parcelContentManager.get(parcelId); } - public CompletableFuture updateParcelContent(UUID parcelId, List items) { + public CompletableFuture updateParcelContent(UUID parcelId, List items) { return this.parcelContentManager.update(parcelId, items); } @@ -113,7 +113,7 @@ public CompletableFuture> getDelivery(UUID parcelId) { return this.deliveryManager.get(parcelId); } - public CompletableFuture renameLocker(UUID lockerUuid, String newName) { + public CompletableFuture renameLocker(UUID lockerUuid, String newName) { return this.lockerManager.rename(lockerUuid, newName); } From fed9c99ee744efe5dcf1196585d4b458b2e87609 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:17:04 +0200 Subject: [PATCH 21/21] claude review fixes --- .../parcellockers/ParcelLockers.java | 2 +- .../parcel/service/AdminParcelService.java | 64 +++++++++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 7d16eb5a..7f897ca1 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -180,7 +180,7 @@ public void onEnable() { ); AdminParcelService adminParcelService = new AdminParcelService( - parcelService, parcelContentManager, deliveryManager, lockerManager, config); + parcelService, parcelContentManager, deliveryManager, lockerManager, config, scheduler); AdminGui adminGUI = new AdminGui( scheduler, miniMessage, config.guiSettings, messageConfig, diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java index 1081e33c..36b6e741 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java @@ -1,5 +1,6 @@ package com.eternalcode.parcellockers.parcel.service; +import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; import com.eternalcode.parcellockers.content.ParcelContentManager; import com.eternalcode.parcellockers.delivery.DeliveryManager; @@ -7,6 +8,7 @@ import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelSize; import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; import java.time.Duration; import java.time.Instant; import java.util.UUID; @@ -19,14 +21,16 @@ public class AdminParcelService { private final DeliveryManager deliveryManager; private final LockerManager lockerManager; private final PluginConfig config; + private final Scheduler scheduler; public AdminParcelService(ParcelService parcelService, ParcelContentManager parcelContentManager, - DeliveryManager deliveryManager, LockerManager lockerManager, PluginConfig config) { + DeliveryManager deliveryManager, LockerManager lockerManager, PluginConfig config, Scheduler scheduler) { this.parcelService = parcelService; this.parcelContentManager = parcelContentManager; this.deliveryManager = deliveryManager; this.lockerManager = lockerManager; this.config = config; + this.scheduler = scheduler; } public static int capacity(ParcelSize size) { @@ -59,7 +63,51 @@ public CompletableFuture changeDescription(Parcel parcel, String des } public CompletableFuture changeStatus(Parcel parcel, ParcelStatus status) { - return this.persist(withStatus(parcel, status)); + Parcel updated = withStatus(parcel, status); + return this.parcelService.update(updated).thenCompose(ignored -> { + if (status == parcel.status()) { + return CompletableFuture.completedFuture(EditResult.ok()); + } + return status == ParcelStatus.SENT ? this.armDelivery(updated) : this.disarmDelivery(updated); + }); + } + + /** + * A SENT parcel must own a delivery row and a scheduled task, otherwise it would sit in SENT + * forever (e.g. after an admin flips a DELIVERED parcel back to SENT). Re-arm both. + */ + private CompletableFuture armDelivery(Parcel parcel) { + return this.deliveryManager.get(parcel.uuid()).thenCompose(existing -> { + if (existing.isPresent()) { + // A delivery row already exists; just make sure a task is armed for it. + this.scheduleSend(parcel, Duration.between(Instant.now(), existing.get().deliveryTimestamp())); + return CompletableFuture.completedFuture(EditResult.ok()); + } + Duration delay = parcel.priority() + ? this.config.settings.priorityParcelSendDuration + : this.config.settings.parcelSendDuration; + return this.deliveryManager.update(parcel.uuid(), Instant.now().plus(delay)) + .thenApply(delivery -> { + this.scheduleSend(parcel, delay); + return EditResult.ok(); + }); + }); + } + + /** A DELIVERED parcel keeps no pending delivery; drop any stray row so no task fires later. */ + private CompletableFuture disarmDelivery(Parcel parcel) { + return this.deliveryManager.get(parcel.uuid()).thenCompose(existing -> { + if (existing.isEmpty()) { + return CompletableFuture.completedFuture(EditResult.ok()); + } + return this.deliveryManager.delete(parcel.uuid()).thenApply(deleted -> EditResult.ok()); + }); + } + + private void scheduleSend(Parcel parcel, Duration delay) { + this.scheduler.runLaterAsync( + new ParcelSendTask(parcel, this.parcelService, this.deliveryManager, this.scheduler), + delay.isNegative() ? Duration.ZERO : delay); } public CompletableFuture changeReceiver(Parcel parcel, UUID receiver) { @@ -95,14 +143,22 @@ public CompletableFuture changePriority(Parcel parcel, boolean newPr if (optionalDelivery.isEmpty()) { return CompletableFuture.completedFuture(EditResult.ok()); } + Instant now = Instant.now(); Instant shifted = shiftedDeliveryTimestamp( optionalDelivery.get().deliveryTimestamp(), parcel.priority(), newPriority, this.config.settings.parcelSendDuration, this.config.settings.priorityParcelSendDuration, - Instant.now()); + now); return this.deliveryManager.update(parcel.uuid(), shifted) - .thenApply(ignoredDelivery -> EditResult.ok()); + .thenApply(ignoredDelivery -> { + // The task scheduled at send/startup still points at the old time; arm a fresh + // task at the new time so an earlier delivery actually fires earlier. The stale + // task self-heals at fire time: it aborts if the parcel is already delivered, + // or reschedules itself if it sees the later timestamp first. + this.scheduleSend(updated, Duration.between(now, shifted)); + return EditResult.ok(); + }); }); }); }