mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[www] add instance clear button for staff
This commit is contained in:
@@ -3213,6 +3213,7 @@ function PublicRoomView({
|
|||||||
<>
|
<>
|
||||||
{isDeveloper() && <StaffRoomTokens room={room} />}
|
{isDeveloper() && <StaffRoomTokens room={room} />}
|
||||||
<StaffListing room={room} onRoomChange={onRoomChange} />
|
<StaffListing room={room} onRoomChange={onRoomChange} />
|
||||||
|
<StaffClearInstances room={room} />
|
||||||
{isDeveloper() && <StaffTakedown room={room} onTakenDown={onTakenDown} />}
|
{isDeveloper() && <StaffTakedown room={room} onTakenDown={onTakenDown} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -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 (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Instances</h2>
|
||||||
|
<p className="hint">
|
||||||
|
Deletes every instance of this room, including ones with players in them. The next player to
|
||||||
|
join gets a fresh instance.
|
||||||
|
</p>
|
||||||
|
<div className="mod-filter-actions">
|
||||||
|
{confirming ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
void run(async () => {
|
||||||
|
const res = await call<{ deleted: number[] }>(
|
||||||
|
`/api/staff/rooms/${room.RoomId}/clear-instances`,
|
||||||
|
{ authed: true, method: 'POST' }
|
||||||
|
)
|
||||||
|
setConfirming(false)
|
||||||
|
const n = res.deleted.length
|
||||||
|
return n === 0
|
||||||
|
? `^${room.Name} had no instances.`
|
||||||
|
: `Cleared ${n} instance${n === 1 ? '' : 's'} of ^${room.Name}.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pending ? 'Clearing…' : 'Confirm clear instances'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linkish"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="submit" onClick={() => setConfirming(true)}>
|
||||||
|
Clear instances
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{done && <p className="ok">{done}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
|
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const { pending, error, run } = useAction()
|
const { pending, error, run } = useAction()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
clearDormSaveBlob,
|
clearDormSaveBlob,
|
||||||
clearPasswordHash,
|
clearPasswordHash,
|
||||||
createGift,
|
createGift,
|
||||||
|
deleteRoomInstancesByRoom,
|
||||||
getAccount,
|
getAccount,
|
||||||
getImageById,
|
getImageById,
|
||||||
getOnlinePlayerIds,
|
getOnlinePlayerIds,
|
||||||
@@ -1016,6 +1017,28 @@ export async function giftRoomTokensHandler(c: Context<App>) {
|
|||||||
return c.json({ roomId, amount, paid, skipped })
|
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<App>) {
|
||||||
|
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
|
* 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.
|
* "Token drop" tab, beside the coach broadcast and the maintenance notice.
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ import {
|
|||||||
setPresence,
|
setPresence,
|
||||||
} from '@repo/domain/src/presence-db'
|
} from '@repo/domain/src/presence-db'
|
||||||
import { getProgression, PROGRESSION_SCHEMA_DDL } from '@repo/domain/src/progression-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
|
// 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.
|
// 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'
|
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/username-changes',
|
||||||
'/api/staff/players/1/clear-password',
|
'/api/staff/players/1/clear-password',
|
||||||
'/api/staff/players/1/clear-dorm',
|
'/api/staff/players/1/clear-dorm',
|
||||||
|
'/api/staff/rooms/1/clear-instances',
|
||||||
'/api/staff/studio-access',
|
'/api/staff/studio-access',
|
||||||
'/api/staff/players/1/grant-plus',
|
'/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 () => {
|
it('grants a player Plus, developers only', async () => {
|
||||||
await updateAccount(env.DB, 8330, { username: 'Plusless' })
|
await updateAccount(env.DB, 8330, { username: 'Plusless' })
|
||||||
const hasPlus = async () =>
|
const hasPlus = async () =>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
bansInForceHandler,
|
bansInForceHandler,
|
||||||
clearDormHandler,
|
clearDormHandler,
|
||||||
clearPasswordHandler,
|
clearPasswordHandler,
|
||||||
|
clearRoomInstancesHandler,
|
||||||
createReportHandler,
|
createReportHandler,
|
||||||
getReportHandler,
|
getReportHandler,
|
||||||
giftItemHandler,
|
giftItemHandler,
|
||||||
@@ -813,6 +814,8 @@ const app = new Hono<App>()
|
|||||||
.post('/api/staff/players/:id/gift-xp', requireDeveloper, giftXpHandler)
|
.post('/api/staff/players/:id/gift-xp', requireDeveloper, giftXpHandler)
|
||||||
// Everyone standing in a room, across all of its instances.
|
// Everyone standing in a room, across all of its instances.
|
||||||
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
|
.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.
|
// The account page's token drop: the same gift to everyone online at once.
|
||||||
.post('/api/staff/online/gift-tokens', requireDeveloper, giftOnlineTokensHandler)
|
.post('/api/staff/online/gift-tokens', requireDeveloper, giftOnlineTokensHandler)
|
||||||
// Its sibling: the same gift to every account whose Discord link holds a role, offline
|
// Its sibling: the same gift to every account whose Discord link holds a role, offline
|
||||||
|
|||||||
@@ -476,6 +476,25 @@ export async function deleteEmptyRoomInstances(
|
|||||||
return results.map((r) => r.id)
|
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<number[]> {
|
||||||
|
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
|
* 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`,
|
* enabled, not already in progress, not set to `Ignore`) that is running `gameVersion`,
|
||||||
|
|||||||
Reference in New Issue
Block a user