Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6a30af6
Add Admin GUI design doc (issue #64)
Jakubk15 Jun 21, 2026
42cd11a
Add parcel content editing to Admin GUI design (issue #64)
Jakubk15 Jun 21, 2026
ce5bc74
Add Admin GUI implementation plan (issue #64)
Jakubk15 Jun 21, 2026
d04f406
feat: add locker rename (repository update + manager rename)
Jakubk15 Jun 21, 2026
22760f9
feat: add parcel content update/upsert path
Jakubk15 Jun 21, 2026
7a5af79
feat: add all-parcels pagination (repo findPage + service getAll)
Jakubk15 Jun 21, 2026
27df868
fix: tighten ParcelFindPageIntegrationTest exception type and assert …
Jakubk15 Jun 21, 2026
ef741b2
feat: add admin GUI config items and admin messages
Jakubk15 Jun 21, 2026
989c7d5
feat: add reusable confirmation dialog factory for admin GUI
Jakubk15 Jun 21, 2026
ece2df5
feat: add admin locker list + edit GUIs (rename, teleport, delete)
Jakubk15 Jun 21, 2026
d75ea01
feat: add admin user list + inspect GUIs
Jakubk15 Jun 21, 2026
3a62498
feat: add AdminParcelService + delivery update path with size/priorit…
Jakubk15 Jun 21, 2026
abec09e
refactor: ParcelSendTask re-fetches state at fire time (safe admin ed…
Jakubk15 Jun 21, 2026
c6b5a63
fix: log failures when ParcelSendTask cleans up a stray delivery on A…
Jakubk15 Jun 21, 2026
e95affa
feat: add admin parcel content editor GUI
Jakubk15 Jun 21, 2026
91c1021
feat: add admin parcel list, pickers, and per-field edit GUI
Jakubk15 Jun 22, 2026
201cd6f
feat: add admin GUI root, bulk actions, and /parcellockers admin entry
Jakubk15 Jun 22, 2026
3d1149e
fix: cascade content deletion on parcel delete + escape names in admi…
Jakubk15 Jun 22, 2026
23a7b64
feat: add sent/received toggle to admin user inspect GUI
Jakubk15 Jun 22, 2026
223c02c
Adjust to self-review
Jakubk15 Jun 22, 2026
fed9c99
claude review fixes
Jakubk15 Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,670 changes: 2,670 additions & 0 deletions docs/superpowers/plans/2026-06-21-admin-gui.md

Large diffs are not rendered by default.

227 changes: 227 additions & 0 deletions docs/superpowers/specs/2026-06-21-admin-gui-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# 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, 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.
- Safe edits to **in-transit** parcels (no stale-snapshot overwrite by the delivery task).

## Non-Goals

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

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) + 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`. |
| `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.
- **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.

### Service layer — `AdminParcelService` (new)

Centralizes risky edits and their side effects so GUIs stay thin. All methods return
`CompletableFuture<EditResult>` 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`.

### Parcel content update path

The write-once content layer gains an update capability:

- `ParcelContentManager#update(UUID parcelId, List<ItemStack> items): CompletableFuture<ParcelContent>`
— 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<Locker>` 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`.
- **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)

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`; 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 including item contents; size-fit + priority-graceful
constraints; priority timing via clamped delta-shift; Option A task refactor; one phased
spec.
13 changes: 11 additions & 2 deletions src/main/java/com/eternalcode/parcellockers/ParcelLockers.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -177,14 +179,21 @@ public void onEnable() {
noticeService
);

AdminParcelService adminParcelService = new AdminParcelService(
parcelService, parcelContentManager, deliveryManager, lockerManager, config, scheduler);

AdminGui adminGUI = new AdminGui(
scheduler, miniMessage, config.guiSettings, messageConfig,
noticeService, guiManager, adminParcelService);

LiteCommandsBuilder<CommandSender, LiteBukkitSettings, ?> liteCommandsBuilder = LiteBukkitFactory.builder(this.getName(), this)
.extension(new LiteAdventureExtension<>())
.argument(Snowflake.class, new SnowflakeArgument(messageConfig))
.message(LiteBukkitMessages.PLAYER_ONLY, messageConfig.playerOnlyCommand)
.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)
Expand Down Expand Up @@ -223,7 +232,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));
})
)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading