mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] support to move subrooms
This commit is contained in:
@@ -822,6 +822,25 @@ export const PublishSaveRequest = z.object({
|
||||
subRoomDataSaveId: z.string().describe('The `SubRoomDataSaveId` to make live'),
|
||||
})
|
||||
|
||||
/** `POST /rooms/{roomId}/subrooms/{subRoomId}/move` — form body naming the destination. */
|
||||
export const MoveSubRoomRequest = z.object({
|
||||
newRoomId: z.string().describe('The `RoomId` to move the subroom into'),
|
||||
})
|
||||
|
||||
/**
|
||||
* What the subroom move answers: the SOURCE room (which no longer lists the subroom) under
|
||||
* `Value`, in the PascalCase `{ Value, Success, Error, error_id }` envelope — the same mixed
|
||||
* casing the unprefixed isBanned check has ({@link IsBannedPascalEnvelope}), NOT the
|
||||
* lowercase `{ success, error, value }` every other subroom mutation answers. Kept apart
|
||||
* deliberately: the client decodes this one with a different reader.
|
||||
*/
|
||||
export const MoveSubRoomEnvelope = z.object({
|
||||
Value: RoomDto.nullable().describe('The source room as it now stands; null on a rejection'),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('The message shown on a rejection; null on success'),
|
||||
error_id: z.string().nullable().describe('Null. Lowercase, unlike its siblings'),
|
||||
})
|
||||
|
||||
/** `POST /rooms/{roomId}/subrooms`. */
|
||||
export const CreateSubRoomRequest = z.object({
|
||||
name: z.string().describe('The new subroom’s name'),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
autocompleteRoomSearch,
|
||||
banPlayerFromRoom,
|
||||
canManageRoom,
|
||||
canManageRoomById,
|
||||
cloneRoom,
|
||||
cloneSubRoom,
|
||||
countRoomsByCreator,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
MessageType,
|
||||
modifySubRoom,
|
||||
movePlayerToDorm,
|
||||
moveSubRoom,
|
||||
publishSubRoomSave,
|
||||
removeCheer,
|
||||
removeFavorite,
|
||||
@@ -115,6 +117,8 @@ import {
|
||||
LoadScreenRequest,
|
||||
MissingLookupParam,
|
||||
ModifySubRoomRequest,
|
||||
MoveSubRoomEnvelope,
|
||||
MoveSubRoomRequest,
|
||||
NameRequest,
|
||||
NOT_FRIENDS_RESPONSE,
|
||||
PagedRooms,
|
||||
@@ -3881,6 +3885,76 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Move a subroom into another room (form body `newRoomId`). Auth-gated (401), and the
|
||||
// caller must be able to MANAGE both rooms — owner or co-owner, unlike the creator-only
|
||||
// gate on the other subroom mutations. Answers the SOURCE room in the PascalCase
|
||||
// `{ Value, Success, Error, error_id }` envelope, not the lowercase one its siblings use.
|
||||
.post(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/move',
|
||||
describeRoute({
|
||||
tags: ['Subrooms'],
|
||||
summary: 'Move a subroom to another room',
|
||||
description: [
|
||||
'Re-parents a subroom onto another room. The subroom keeps its `SubRoomId`, its',
|
||||
'save history and its permission overrides — all keyed by that globally-unique id',
|
||||
'— and only its `RoomId` changes. It refuses to move a room’s only subroom, for the',
|
||||
'reason delete refuses it: the room would be left with no scene to load. Moving a',
|
||||
'subroom onto the room it is already in is refused as well.',
|
||||
'',
|
||||
'The caller must be able to manage BOTH rooms — owner or co-owner (`CreatorAccountId`',
|
||||
'or a Creator/CoOwner role) — not just the one the subroom leaves.',
|
||||
'',
|
||||
'Answers the SOURCE room, which no longer lists the subroom, under `Value` in the',
|
||||
'PascalCase `{ Value, Success, Error, error_id }` envelope: NOT the lowercase',
|
||||
'`{ success, error, value }` the other subroom mutations answer. A rejection is HTTP',
|
||||
'200 with `Success: false`, `Value: null` and the message in `Error`; only a missing',
|
||||
'token is a 401.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam, subRoomIdParam],
|
||||
requestBody: form(MoveSubRoomRequest, 'The destination room'),
|
||||
responses: {
|
||||
200: json(MoveSubRoomEnvelope, 'The source room, or a rejection with `Success: false`'),
|
||||
401: { description: 'No bearer token (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
const refuse = (error: string) =>
|
||||
c.json({ Value: null, Success: false, Error: error, error_id: null })
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const newRoomId = Number.parseInt(String(body.newRoomId ?? ''), 10)
|
||||
if (!Number.isInteger(newRoomId)) return refuse('You must provide a valid newRoomId!')
|
||||
if (newRoomId === roomId) return refuse('This subroom is already in that room!')
|
||||
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return refuse('This room does not exist!')
|
||||
if (!canManageRoom(room, accountId)) return refuse('You are not the owner of this room!')
|
||||
const canManageTarget = await canManageRoomById(c.env.DB, newRoomId, accountId)
|
||||
if (canManageTarget === null) return refuse('The destination room does not exist!')
|
||||
if (!canManageTarget) return refuse('You are not the owner of the destination room!')
|
||||
|
||||
const result = await moveSubRoom(c.env.DB, roomId, subRoomId, newRoomId)
|
||||
if (!result.ok) {
|
||||
return refuse(
|
||||
result.reason === 'last_subroom'
|
||||
? "You can't move a room's only subroom!"
|
||||
: 'This subroom does not exist!'
|
||||
)
|
||||
}
|
||||
|
||||
// Both rooms changed shape for the caller's other sessions.
|
||||
await pushRoomUpdate(c, accountId, result.room)
|
||||
const target = await getRoomById(c.env.DB, newRoomId)
|
||||
if (target) await pushRoomUpdate(c, accountId, target)
|
||||
return c.json({ Value: result.room, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
|
||||
// Create a new (empty) subroom in a room (form body `name`). Auth-gated (401) and
|
||||
// owner-only. Mints a fresh globally-unique SubRoomId, bases the scene/capacity on the
|
||||
// room's first subroom, notifies the owner (RoomUpdate), and returns the updated ROOM
|
||||
|
||||
@@ -4819,6 +4819,84 @@ describe('rooms endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/subrooms/:sid/move re-parents a subroom when the caller manages both rooms', async () => {
|
||||
// Source 710 is owned by 1 with two subrooms; target 711 is owned by 5 with 1 as a
|
||||
// CO-OWNER (the move gate is manage, not own); 712 is somebody else's entirely.
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 710,
|
||||
Name: 'MoveSource',
|
||||
CreatorAccountId: 1,
|
||||
SubRooms: [
|
||||
{ SubRoomId: 910, UnitySceneId: 'x', MaxPlayers: 4 },
|
||||
{ SubRoomId: 911, UnitySceneId: 'x', MaxPlayers: 4 },
|
||||
],
|
||||
})
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 711,
|
||||
Name: 'MoveTarget',
|
||||
CreatorAccountId: 5,
|
||||
Roles: [{ AccountId: 1, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
|
||||
SubRooms: [{ SubRoomId: 912, UnitySceneId: 'x', MaxPlayers: 4 }],
|
||||
})
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 712,
|
||||
Name: 'NotMine',
|
||||
CreatorAccountId: 999,
|
||||
SubRooms: [{ SubRoomId: 913, UnitySceneId: 'x', MaxPlayers: 4 }],
|
||||
})
|
||||
const move = async (roomId: number, subRoomId: number, newRoomId: string, sub?: string) =>
|
||||
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/move`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(sub ? await bearer(sub) : {}),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({ newRoomId }).toString(),
|
||||
})
|
||||
type Envelope = {
|
||||
Value: { RoomId: number; SubRooms: Array<{ SubRoomId: number; RoomId: number }> } | null
|
||||
Success: boolean
|
||||
Error: string | null
|
||||
error_id: null
|
||||
}
|
||||
const envelope = async (res: Response) => (await res.json()) as Envelope
|
||||
|
||||
// No token → 401. Every other refusal is a 200 with Success:false and Value:null.
|
||||
expect((await move(710, 910, '711')).status).toBe(401)
|
||||
const refused = async (res: Response) => {
|
||||
expect(res.status).toBe(200)
|
||||
const body = await envelope(res)
|
||||
expect(body).toMatchObject({ Value: null, Success: false, error_id: null })
|
||||
expect(typeof body.Error).toBe('string')
|
||||
}
|
||||
await refused(await move(710, 910, '711', '999')) // can't manage the source
|
||||
await refused(await move(710, 910, '712', '1')) // can't manage the target
|
||||
await refused(await move(710, 910, '424242', '1')) // no such target
|
||||
await refused(await move(710, 910, '710', '1')) // already there
|
||||
await refused(await move(710, 910, '', '1')) // no newRoomId
|
||||
await refused(await move(710, 99999, '711', '1')) // no such subroom
|
||||
await refused(await move(712, 913, '711', '999')) // 999 owns 712 but not 711
|
||||
// Nothing moved on any of those.
|
||||
expect(await subRoomOf(710, 910)).toBeDefined()
|
||||
expect(await subRoomOf(711, 910)).toBeUndefined()
|
||||
|
||||
// Owner of the source and co-owner of the target → moved. Value is the SOURCE room,
|
||||
// which no longer lists the subroom, in the PascalCase envelope.
|
||||
const res = await move(710, 910, '711', '1')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await envelope(res)
|
||||
expect(body).toMatchObject({ Success: true, Error: null, error_id: null })
|
||||
expect(body.Value?.RoomId).toBe(710)
|
||||
expect(body.Value?.SubRooms.map((s) => s.SubRoomId)).toEqual([911])
|
||||
// It now reads as the target's, with its RoomId re-injected from the column.
|
||||
expect(await subRoomOf(710, 910)).toBeUndefined()
|
||||
expect(await subRoomOf(711, 910)).toMatchObject({ SubRoomId: 910, RoomId: 711 })
|
||||
|
||||
// The source's last subroom can't leave — the room would have no scene to load.
|
||||
await refused(await move(710, 911, '711', '1'))
|
||||
expect(await subRoomOf(710, 911)).toBeDefined()
|
||||
})
|
||||
|
||||
it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => {
|
||||
// The old per-room `max(SubRoomId)+1` allocator would mint id 3 for room 2's clone —
|
||||
// colliding with another room that already owns subroom 3. The subroom table's
|
||||
@@ -5225,6 +5303,7 @@ describe('rooms endpoints', () => {
|
||||
'POST /rooms/{roomId}/subrooms',
|
||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
|
||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/move',
|
||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save',
|
||||
'PUT /rooms/{roomId}/accessibility',
|
||||
'PUT /rooms/{roomId}/cloning',
|
||||
|
||||
Reference in New Issue
Block a user