diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 989d3d8..7afbd57 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -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'), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index b06ee89..bdf34a4 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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() } ) + // 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 + 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 diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index b7d163c..b5d01ab 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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', diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 99377f8..2b52668 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -1632,6 +1632,41 @@ export async function deleteSubRoom( 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 { data: string visits: number