diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index ae022cf..fb8f5db 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -404,6 +404,7 @@ public class AdminController( r.IsAGRoom, r.IsDormRoom, r.CreatorPlayerId, + r.State, BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id), }) .ToListAsync(); @@ -448,6 +449,21 @@ public class AdminController( return Ok(new { room.Id, room.State }); } + /// POST api/admin/v1/rooms/{id}/restore — the inverse of + /// the soft-archive above: clears State back to 0 (Active) so the room + /// shows up in browse/search again. Idempotent; safe on a non-archived + /// room (just re-asserts State=0). + [HttpPost("rooms/{id:long}/restore")] + public async Task RestoreRoom(long id) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + room.State = 0; // Active + await LogAsync("restore_room", "room", id, ""); + await db.SaveChangesAsync(); + return Ok(new { room.Id, room.State }); + } + // The bulk "purge every custom room" endpoint was removed: a single // wrong click could wipe every imported map in one shot. Per-room // purge (below) is the only hard-delete path, gated by two @@ -487,8 +503,11 @@ public class AdminController( { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); if (room is null) return NotFound(); - if (room.IsDormRoom) - return BadRequest(new { error = "refuses_to_hard_delete_dorm", note = "Dorms are per-player state; never purge." }); + // Dorms are per-player state — purging one wipes that player's dorm + // contents, but it's recoverable: EnsurePersonalDormAsync regenerates + // a fresh dorm on their next join. An admin sometimes needs to nuke a + // corrupted/test dorm, so we ALLOW it; the two typed-name + // confirmations below are the guard against an accidental wipe. if (room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId) return BadRequest(new { error = "refuses_to_hard_delete_seeded_room", note = "This row is owned by the system seed account — soft-archive via DELETE /rooms/{id} instead." }); diff --git a/DorkNet.Server/admin-ui/src/lib/types.ts b/DorkNet.Server/admin-ui/src/lib/types.ts index ba0797f..2c13005 100644 --- a/DorkNet.Server/admin-ui/src/lib/types.ts +++ b/DorkNet.Server/admin-ui/src/lib/types.ts @@ -84,6 +84,7 @@ export interface Room { isAGRoom: boolean; isDormRoom: boolean; creatorPlayerId: number; + state: number; blobCount: number; } diff --git a/DorkNet.Server/admin-ui/src/pages/Room.tsx b/DorkNet.Server/admin-ui/src/pages/Room.tsx index 57f1b85..3a13e34 100644 --- a/DorkNet.Server/admin-ui/src/pages/Room.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Room.tsx @@ -1006,7 +1006,11 @@ function DangerTab({ room, onAfter }: { room: RoomDetail; onAfter: () => void }) const [confirm2, setConfirm2] = useState(''); const [busy, setBusy] = useState(false); - const isCustom = !room.isDormRoom && room.owner.id !== 1; + // Anything not owned by the system seed account (id 1) can be purged — + // including dorms. Purging a dorm wipes its contents but the player's dorm + // regenerates on next join; the two typed-name confirmations are the guard. + const isCustom = room.owner.id !== 1; + const isArchived = room.state === 1; const purgeReady = confirm1 === room.name && confirm2 === room.name && !busy; const archive = async () => { @@ -1019,6 +1023,16 @@ function DangerTab({ room, onAfter }: { room: RoomDetail; onAfter: () => void }) } }; + const restore = async () => { + try { + await api(`/rooms/${room.id}/restore`, { method: 'POST' }); + toast.push(`Restored ${room.name}`, 'success'); + onAfter(); + } catch (e) { + toast.push((e as Error).message, 'error'); + } + }; + const purge = async () => { if (!purgeReady) return; setBusy(true); @@ -1041,14 +1055,22 @@ function DangerTab({ room, onAfter }: { room: RoomDetail; onAfter: () => void })
-

Archive

+

{isArchived ? 'Restore' : 'Archive'}

- Soft delete — sets State=Archived. The room stops appearing in browse/search but stays restorable. + {isArchived + ? 'This room is archived (State=1). Restore sets State=0 (Active) so it shows up in browse/search again.' + : 'Soft delete — sets State=Archived. The room stops appearing in browse/search but stays restorable.'}

- + {isArchived ? ( + + ) : ( + + )}
{isCustom && ( diff --git a/DorkNet.Server/admin-ui/src/pages/Rooms.tsx b/DorkNet.Server/admin-ui/src/pages/Rooms.tsx index 5d71948..9387b43 100644 --- a/DorkNet.Server/admin-ui/src/pages/Rooms.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Rooms.tsx @@ -79,6 +79,16 @@ export function Rooms() { } }; + const restore = async (r: Room) => { + try { + await api(`/rooms/${r.id}/restore`, { method: 'POST' }); + toast.push(`Restored ${r.name}`, 'success'); + refresh(); + } catch (e) { + toast.push((e as Error).message, 'error'); + } + }; + return (
{r.blobCount} Manage - - {!r.isDormRoom && ( - + ) : ( + )} + ))}