From d819bdbe3d1fb3e0f6a55107ea51064888c4565e Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 26 Oct 2026 18:27:59 -0400 Subject: [PATCH] [www] add instance clear button for staff --- apps/www/src/client/App.tsx | 59 +++++++++++++++++++++++ apps/www/src/staff.ts | 23 +++++++++ apps/www/src/test/integration/api.test.ts | 32 +++++++++++- apps/www/src/www.app.ts | 3 ++ packages/domain/src/room-instance-db.ts | 19 ++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index ccafb37..4a92268 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -3213,6 +3213,7 @@ function PublicRoomView({ <> {isDeveloper() && } + {isDeveloper() && } )} @@ -3351,6 +3352,64 @@ function StaffListing({ ) } +/** + * Delete every instance of the room, for one whose sessions are stuck. Two steps, like the + * dorm clear: nothing is lost for good, but anyone inside is cut off from their session. + */ +function StaffClearInstances({ room }: { room: OwnedRoom }) { + const [confirming, setConfirming] = useState(false) + const { pending, error, done, run } = useAction() + + return ( +
+

Instances

+

+ Deletes every instance of this room, including ones with players in them. The next player to + join gets a fresh instance. +

+
+ {confirming ? ( + <> + + + + ) : ( + + )} +
+ {error &&

{error}

} + {done &&

{done}

} +
+ ) +} + function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) { const [confirming, setConfirming] = useState(false) const { pending, error, run } = useAction() diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index ede4d9b..e92023d 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -4,6 +4,7 @@ import { clearDormSaveBlob, clearPasswordHash, createGift, + deleteRoomInstancesByRoom, getAccount, getImageById, getOnlinePlayerIds, @@ -1016,6 +1017,28 @@ export async function giftRoomTokensHandler(c: Context) { return c.json({ roomId, amount, paid, skipped }) } +/** + * Delete every instance of a room from `room_instance`, from the room's own page. For a + * room whose sessions have gone bad: the next player to matchmake in gets a fresh one. + * Clearing a room with no instances succeeds and deletes nothing. + */ +export async function clearRoomInstancesHandler(c: Context) { + const roomId = Number(c.req.param('roomId')) + if (!Number.isInteger(roomId) || roomId <= 0) { + return c.json({ error: 'A numeric room id is required' }, 400) + } + + const deleted = await deleteRoomInstancesByRoom(c.env.DB, roomId) + + await recordPlayerAudit(c, 'clear_room_instances', { roomId, deleted }) + logger.info('staff cleared room instances', { + moderatorId: staffId(c), + roomId, + deletedCount: deleted.length, + }) + return c.json({ roomId, deleted }) +} + /** * Send tokens to EVERYONE ONLINE right now — the server-wide drop behind the account page's * "Token drop" tab, beside the coach broadcast and the maintenance notice. diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index cbdb762..205d2ec 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -22,7 +22,11 @@ import { setPresence, } from '@repo/domain/src/presence-db' import { getProgression, PROGRESSION_SCHEMA_DDL } from '@repo/domain/src/progression-db' -import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db' +import { + createRoomInstance, + getRoomInstancesByRoom, + ROOM_INSTANCE_SCHEMA_DDL, +} from '@repo/domain/src/room-instance-db' // Banning a player moves them into their own dorm, which is a room (created on demand) with // a subroom — so those tables have to be here, as they are on the shared database. import { getOrCreateDormRoom, ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db' @@ -1087,6 +1091,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy '/api/staff/players/1/username-changes', '/api/staff/players/1/clear-password', '/api/staff/players/1/clear-dorm', + '/api/staff/rooms/1/clear-instances', '/api/staff/studio-access', '/api/staff/players/1/grant-plus', ] @@ -2472,6 +2477,31 @@ it('clears a player’s dorm by publishing an empty save over the newest one', a ]) }) +it('clears every instance of a room, for any staff member', async () => { + const make = (roomId: number, photonRoomId: string) => + createRoomInstance(env.DB, { ownerAccountId: 8110, roomId, photonRoomId }) + const a = await make(7740, 'clear-a') + const b = await make(7740, 'clear-b') + const other = await make(7741, 'clear-other') + + // A moderator, not only a developer, may clear a room. + const res = await staffPost('/api/staff/rooms/7740/clear-instances', 8101, {}) + expect(res.status).toBe(200) + const body = (await res.json()) as { roomId: number; deleted: number[] } + expect(body.roomId).toBe(7740) + expect(body.deleted.toSorted()).toEqual([a.roomInstanceId, b.roomInstanceId].toSorted()) + expect(await getRoomInstancesByRoom(env.DB, 7740)).toEqual([]) + // Another room's instances are untouched. + expect((await getRoomInstancesByRoom(env.DB, 7741)).map((i) => i.roomInstanceId)).toEqual([ + other.roomInstanceId, + ]) + + // Clearing an empty room succeeds and deletes nothing. + const again = await staffPost('/api/staff/rooms/7740/clear-instances', 8101, {}) + expect(await again.json()).toEqual({ roomId: 7740, deleted: [] }) + expect((await staffPost('/api/staff/rooms/abc/clear-instances', 8101, {})).status).toBe(400) +}) + it('grants a player Plus, developers only', async () => { await updateAccount(env.DB, 8330, { username: 'Plusless' }) const hasPlus = async () => diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index 2024a71..c21aaab 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -51,6 +51,7 @@ import { bansInForceHandler, clearDormHandler, clearPasswordHandler, + clearRoomInstancesHandler, createReportHandler, getReportHandler, giftItemHandler, @@ -813,6 +814,8 @@ const app = new Hono() .post('/api/staff/players/:id/gift-xp', requireDeveloper, giftXpHandler) // Everyone standing in a room, across all of its instances. .post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler) + // Delete every live instance of a room — for one whose sessions are stuck. All staff. + .post('/api/staff/rooms/:roomId/clear-instances', clearRoomInstancesHandler) // The account page's token drop: the same gift to everyone online at once. .post('/api/staff/online/gift-tokens', requireDeveloper, giftOnlineTokensHandler) // Its sibling: the same gift to every account whose Discord link holds a role, offline diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts index 325846d..4332d2d 100644 --- a/packages/domain/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -476,6 +476,25 @@ export async function deleteEmptyRoomInstances( return results.map((r) => r.id) } +/** + * Delete EVERY instance of a room, occupied or not — the staff "Clear instances" button + * for a room whose sessions are stuck. Presence is left alone: a player still in a + * deleted instance keeps their row until it expires or they move, and their next + * matchmake lands them in a fresh instance. + * + * Returns the ids deleted. + */ +export async function deleteRoomInstancesByRoom(db: D1Database, roomId: number): Promise { + const { results } = await db + .prepare( + `DELETE FROM room_instance WHERE room_id = ?1 + RETURNING json_extract(data, '$.roomInstanceId') AS id` + ) + .bind(roomId) + .all<{ id: number }>() + return results.map((r) => r.id) +} + /** * The oldest joinable public instance of a room (not private, not full, joins * enabled, not already in progress, not set to `Ignore`) that is running `gameVersion`,