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'),
|
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`. */
|
/** `POST /rooms/{roomId}/subrooms`. */
|
||||||
export const CreateSubRoomRequest = z.object({
|
export const CreateSubRoomRequest = z.object({
|
||||||
name: z.string().describe('The new subroom’s name'),
|
name: z.string().describe('The new subroom’s name'),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
autocompleteRoomSearch,
|
autocompleteRoomSearch,
|
||||||
banPlayerFromRoom,
|
banPlayerFromRoom,
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
|
canManageRoomById,
|
||||||
cloneRoom,
|
cloneRoom,
|
||||||
cloneSubRoom,
|
cloneSubRoom,
|
||||||
countRoomsByCreator,
|
countRoomsByCreator,
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
MessageType,
|
MessageType,
|
||||||
modifySubRoom,
|
modifySubRoom,
|
||||||
movePlayerToDorm,
|
movePlayerToDorm,
|
||||||
|
moveSubRoom,
|
||||||
publishSubRoomSave,
|
publishSubRoomSave,
|
||||||
removeCheer,
|
removeCheer,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
@@ -115,6 +117,8 @@ import {
|
|||||||
LoadScreenRequest,
|
LoadScreenRequest,
|
||||||
MissingLookupParam,
|
MissingLookupParam,
|
||||||
ModifySubRoomRequest,
|
ModifySubRoomRequest,
|
||||||
|
MoveSubRoomEnvelope,
|
||||||
|
MoveSubRoomRequest,
|
||||||
NameRequest,
|
NameRequest,
|
||||||
NOT_FRIENDS_RESPONSE,
|
NOT_FRIENDS_RESPONSE,
|
||||||
PagedRooms,
|
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
|
// 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
|
// 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
|
// 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 () => {
|
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 —
|
// 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
|
// 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',
|
||||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
||||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
|
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
|
||||||
|
'POST /rooms/{roomId}/subrooms/{subRoomId}/move',
|
||||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save',
|
'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save',
|
||||||
'PUT /rooms/{roomId}/accessibility',
|
'PUT /rooms/{roomId}/accessibility',
|
||||||
'PUT /rooms/{roomId}/cloning',
|
'PUT /rooms/{roomId}/cloning',
|
||||||
|
|||||||
@@ -1632,6 +1632,41 @@ export async function deleteSubRoom(
|
|||||||
return { ok: true, room }
|
return { ok: true, room }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a subroom from one room to another (`POST …/subrooms/{subRoomId}/move`). A subroom
|
||||||
|
* is its own row keyed by a globally-unique id, and its saves and permission overrides hang
|
||||||
|
* off that id rather than the room's, so the move is one column: `room_id`. The saves,
|
||||||
|
* the current/staged pointers and the permissions all travel with it untouched. The
|
||||||
|
* subroom's `RoomId` is re-injected from the column on read, so the blob needs nothing.
|
||||||
|
*
|
||||||
|
* Refuses to move a room's only subroom, for the reason {@link deleteSubRoom} refuses to
|
||||||
|
* delete it: the room would be left with no scene to load. The caller checks the target
|
||||||
|
* room exists and that it may manage both rooms — this only checks the subroom's side.
|
||||||
|
*
|
||||||
|
* Returns the SOURCE room, hydrated, which no longer lists the subroom — that is what the
|
||||||
|
* client re-renders from — or a reason: `not_found` (no such subroom in that room) /
|
||||||
|
* `last_subroom`.
|
||||||
|
*/
|
||||||
|
export async function moveSubRoom(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
subRoomId: number,
|
||||||
|
newRoomId: number
|
||||||
|
): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'last_subroom' }> {
|
||||||
|
const subRooms = await getSubRooms(db, roomId)
|
||||||
|
if (!subRooms.some((s) => s.SubRoomId === subRoomId)) return { ok: false, reason: 'not_found' }
|
||||||
|
if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' }
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE subroom SET room_id = ?3 WHERE room_id = ?1 AND sub_room_id = ?2')
|
||||||
|
.bind(roomId, subRoomId, newRoomId)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const room = await getRoomById(db, roomId)
|
||||||
|
if (!room) return { ok: false, reason: 'not_found' }
|
||||||
|
return { ok: true, room }
|
||||||
|
}
|
||||||
|
|
||||||
interface RoomRow {
|
interface RoomRow {
|
||||||
data: string
|
data: string
|
||||||
visits: number
|
visits: number
|
||||||
|
|||||||
Reference in New Issue
Block a user